Skip to content

Commit df1d8f6

Browse files
fix: symlink libpython for MuJoCo mjpython on macOS with uv (#1503)
* fix: symlink libpython for MuJoCo mjpython on macOS with uv When Python is installed via uv, mjpython fails because it expects libpython at .venv/lib/ but uv places it in its own managed directory. Add a LibPythonConfiguratorMacOS system configurator that auto-creates the symlink during system setup before launching the MuJoCo subprocess. * CI code cleanup * fix: handle broken symlinks and OSError in libpython configurator * CI code cleanup * fix: update test to expect LibPythonConfiguratorMacOS in Darwin checks * fix: detect broken symlinks in libpython check target.exists() follows symlinks and returns False for dangling ones, so the extra is_symlink() guard was silently skipping broken symlinks. The fix() method already handles unlinking stale symlinks before recreating them. --------- Co-authored-by: spomichter <pomichterstash@gmail.com>
1 parent 08f01e1 commit df1d8f6

3 files changed

Lines changed: 82 additions & 1 deletion

File tree

dimos/protocol/service/system_configurator/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
MulticastConfiguratorLinux,
3131
MulticastConfiguratorMacOS,
3232
)
33+
from dimos.protocol.service.system_configurator.libpython import LibPythonConfiguratorMacOS
3334

3435

3536
# TODO: This is a configurator API issue and inserted here temporarily
@@ -56,6 +57,7 @@ def lcm_configurators() -> list[SystemConfigurator]:
5657
MulticastConfiguratorMacOS(loopback_interface="lo0"),
5758
BufferConfiguratorMacOS(),
5859
MaxFileConfiguratorMacOS(), # TODO: this is not LCM related and shouldn't be here at all
60+
LibPythonConfiguratorMacOS(),
5961
]
6062
return []
6163

@@ -65,6 +67,7 @@ def lcm_configurators() -> list[SystemConfigurator]:
6567
"BufferConfiguratorLinux",
6668
"BufferConfiguratorMacOS",
6769
"ClockSyncConfigurator",
70+
"LibPythonConfiguratorMacOS",
6871
"MaxFileConfiguratorMacOS",
6972
"MulticastConfiguratorLinux",
7073
"MulticastConfiguratorMacOS",
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
# Copyright 2026 Dimensional Inc.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""Ensure libpython is available in the venv for MuJoCo's mjpython on macOS.
16+
17+
When Python is installed via uv, mjpython fails because it expects
18+
libpython at .venv/lib/ but uv places it in its own managed directory.
19+
This configurator creates a symlink so mjpython can find the library.
20+
"""
21+
22+
from __future__ import annotations
23+
24+
import logging
25+
from pathlib import Path
26+
import platform
27+
import sys
28+
29+
from dimos.protocol.service.system_configurator.base import SystemConfigurator
30+
31+
logger = logging.getLogger(__name__)
32+
33+
34+
class LibPythonConfiguratorMacOS(SystemConfigurator):
35+
"""Create a libpython symlink in the venv lib dir if missing (macOS only)."""
36+
37+
critical = False
38+
39+
def __init__(self) -> None:
40+
self._missing: list[tuple[Path, Path]] = [] # (symlink_target, real_dylib)
41+
42+
def check(self) -> bool:
43+
if platform.system() != "Darwin":
44+
return True
45+
46+
self._missing.clear()
47+
venv_lib = Path(sys.prefix) / "lib"
48+
real_lib = Path(sys.executable).resolve().parent.parent / "lib"
49+
50+
for dylib in real_lib.glob("libpython*.dylib"):
51+
target = venv_lib / dylib.name
52+
if not target.exists():
53+
self._missing.append((target, dylib))
54+
55+
return not self._missing
56+
57+
def explanation(self) -> str | None:
58+
if not self._missing:
59+
return None
60+
lines = []
61+
for symlink_path, real_path in self._missing:
62+
lines.append(f"- Symlink {symlink_path} -> {real_path} (for mjpython)")
63+
return "\n".join(lines)
64+
65+
def fix(self) -> None:
66+
for symlink_path, real_path in self._missing:
67+
try:
68+
symlink_path.parent.mkdir(parents=True, exist_ok=True)
69+
if symlink_path.is_symlink():
70+
symlink_path.unlink()
71+
symlink_path.symlink_to(real_path)
72+
logger.warning("Created symlink %s -> %s for mjpython", symlink_path, real_path)
73+
except OSError as error:
74+
logger.warning(
75+
"Failed to create symlink %s -> %s: %s", symlink_path, real_path, error
76+
)

dimos/protocol/service/test_lcmservice.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
from dimos.protocol.service.system_configurator import (
2727
BufferConfiguratorLinux,
2828
BufferConfiguratorMacOS,
29+
LibPythonConfiguratorMacOS,
2930
MaxFileConfiguratorMacOS,
3031
MulticastConfiguratorLinux,
3132
MulticastConfiguratorMacOS,
@@ -56,10 +57,11 @@ def test_creates_macos_checks_on_darwin(self) -> None:
5657
autoconf()
5758
mock_configure.assert_called_once()
5859
checks = mock_configure.call_args[0][0]
59-
assert len(checks) == 3
60+
assert len(checks) == 4
6061
assert isinstance(checks[0], MulticastConfiguratorMacOS)
6162
assert isinstance(checks[1], BufferConfiguratorMacOS)
6263
assert isinstance(checks[2], MaxFileConfiguratorMacOS)
64+
assert isinstance(checks[3], LibPythonConfiguratorMacOS)
6365
assert checks[0].loopback_interface == "lo0"
6466

6567
def test_passes_check_only_flag(self) -> None:

0 commit comments

Comments
 (0)