diff --git a/python/MANIFEST.in b/python/MANIFEST.in index 83d8996a..b13b0582 100644 --- a/python/MANIFEST.in +++ b/python/MANIFEST.in @@ -1,3 +1,4 @@ include LICENSE *.md recursive-include mujoco *.h *.cc *.mm CMakeLists.txt Simulate*.cmake recursive-include cmake *.cmake +recursive-include mujoco/mjpython mjpython.* Info.plist diff --git a/python/mujoco/CMakeLists.txt b/python/mujoco/CMakeLists.txt index d949b26e..c8c32aa5 100644 --- a/python/mujoco/CMakeLists.txt +++ b/python/mujoco/CMakeLists.txt @@ -22,6 +22,9 @@ set(CMAKE_POLICY_DEFAULT_CMP0069 NEW) enable_language(C) enable_language(CXX) +if(APPLE) + enable_language(OBJCXX) +endif() if(MSVC AND MSVC_VERSION GREATER_EQUAL 1927) set(CMAKE_CXX_STANDARD 20) # For forceinline lambdas. @@ -407,6 +410,13 @@ set(LIBRARIES_FOR_WHEEL "$" ) +if(APPLE) + add_executable(mjpython mjpython/mjpython.mm) + target_include_directories(mjpython PRIVATE "${Python3_INCLUDE_DIRS}") + target_link_libraries(mjpython PRIVATE "-framework Cocoa") + set(LIBRARIES_FOR_WHEEL "${LIBRARIES_FOR_WHEEL}" "$") +endif() + if(MUJOCO_PYTHON_MAKE_WHEEL) add_custom_target( wheel ALL @@ -434,4 +444,7 @@ if(MUJOCO_PYTHON_MAKE_WHEEL) _structs mujoco ) + if(APPLE) + add_dependencies(wheel mjpython) + endif() endif() diff --git a/python/mujoco/mjpython/Info.plist b/python/mujoco/mjpython/Info.plist new file mode 100644 index 00000000..7503ffb9 --- /dev/null +++ b/python/mujoco/mjpython/Info.plist @@ -0,0 +1,34 @@ + + + + + CFBundleName + mjpython + CFBundleIdentifier + org.mujoco.mjpython + CFBundleVersion + 2.3.2 + CFBundleGetInfoString + 2.3.2 + CFBundleLongVersionString + 2.3.2 + CFBundleShortVersionString + 2.3.2 + CFBundleExecutable + mjpython + CFBundleIconFile + mjpython.icns + CFBundlePackageType + APPL + NSHumanReadableCopyright + Copyright 2023 DeepMind Technologies Limited. + CFBundleInfoDictionaryVersion + 6.0 + CFBundleDevelopmentRegion + en + CFBundleSupportedPlatforms + + MacOSX + + + diff --git a/python/mujoco/mjpython/mjpython.icns b/python/mujoco/mjpython/mjpython.icns new file mode 100644 index 00000000..0c990611 Binary files /dev/null and b/python/mujoco/mjpython/mjpython.icns differ diff --git a/python/mujoco/mjpython/mjpython.mm b/python/mujoco/mjpython/mjpython.mm new file mode 100644 index 00000000..bd3730ab --- /dev/null +++ b/python/mujoco/mjpython/mjpython.mm @@ -0,0 +1,298 @@ +// Copyright 2023 DeepMind Technologies Limited +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#import +#import +#import + +#import +#import +#import + +#import +#import + +// Wrap Objective-C Cocoa calls into C-style functions with default visibility, +// so that we can dlsym and call them from Python via ctypes. +extern "C" { +__attribute__((used)) void mjpython_hide_dock_icon() { + [NSApp setActivationPolicy:NSApplicationActivationPolicyAccessory]; +} +__attribute__((used)) void mjpython_show_dock_icon() { + [NSApp setActivationPolicy:NSApplicationActivationPolicyRegular]; +} +} + +// TODO(b/273744079): Remove Python 3.7 code after end-of-life (27 Jun 2023). +namespace { +struct { +#define CPYTHON_FN(fname) decltype(&::fname) fname + +#if PY_MINOR_VERSION >= 8 + CPYTHON_FN(Py_InitializeFromConfig); + CPYTHON_FN(Py_RunMain); + CPYTHON_FN(PyConfig_Clear); + CPYTHON_FN(PyConfig_InitPythonConfig); + CPYTHON_FN(PyConfig_SetBytesArgv); +#else + CPYTHON_FN(Py_DecodeLocale); + CPYTHON_FN(Py_Initialize); + CPYTHON_FN(Py_Main); + CPYTHON_FN(PyMem_RawFree); + CPYTHON_FN(Py_SetProgramName); +#endif + + // go/keep-sorted start + CPYTHON_FN(Py_FinalizeEx); + CPYTHON_FN(PyGILState_Ensure); + CPYTHON_FN(PyGILState_Release); + CPYTHON_FN(PyRun_SimpleStringFlags); + // go/keep-sorted end + +#undef CPYTHON_FN +} cpython; + +std::atomic_bool py_initialized = false; + +struct Args { + int argc; + char** argv; +}; + +// The Python main thread (distinct from the macOS main thread, which executes the main function). +void* mjpython_pymain(void* vargs) { + Args* args = static_cast(vargs); + PyGILState_STATE gil; + + // Initialize the Python interpreter. +#if PY_MINOR_VERSION >= 8 + PyConfig config; + cpython.PyConfig_InitPythonConfig(&config); + cpython.PyConfig_SetBytesArgv(&config, args->argc, args->argv); + cpython.Py_InitializeFromConfig(&config); + cpython.PyConfig_Clear(&config); +#else + // Convert each argv to wchar_t* (needed for Py_Main). + wchar_t** wargv = static_cast(std::calloc(args->argc, sizeof(wchar_t*))); + for (int i = 0; i < args->argc; ++i) { + wargv[i] = cpython.Py_DecodeLocale(args->argv[i], nullptr); + } + cpython.Py_SetProgramName(wargv[0]); + cpython.Py_Initialize(); +#endif + + // Set up the condition variable to pass control back to the macOS main thread. + gil = cpython.PyGILState_Ensure(); + cpython.PyRun_SimpleStringFlags("import threading; cond = threading.Condition()", nullptr); + py_initialized.store(true); + + // Wait until GLFW is initialized on macOS main thread, set up the queue and an atexit hook + // to enqueue a termination flag upon exit. + cpython.PyRun_SimpleStringFlags(R"( +import atexit + +# The mujoco.viewer module should only be imported here after glfw.init() in the macOS main thread. +with cond: + cond.wait() +import mujoco.viewer + +# Similar to a queue.Queue(maxsize=1), but where only one active task is allowed at a time. +# With queue.Queue(1), another item is allowed to be enqueued before task_done is called. +class _MjPythonImpl(mujoco.viewer._MjPythonBase): + + # Termination statuses + NOT_TERMINATED = 0 + TERMINATION_REQUESTED = 1 + TERMINATION_ACCEPTED = 2 + TERMINATED = 3 + + def __init__(self): + self._cond = threading.Condition() + self._model_data = None + self._termination = self.__class__.NOT_TERMINATED + self._busy = False + + def launch_on_ui_thread(self, model, data): + with self._cond: + if self._busy or self._model_data is not None: + raise RuntimeError('another MuJoCo viewer is already open') + else: + self._model_data = (model, data) + self._cond.notify() + + def terminate(self): + with self._cond: + self._termination = self.__class__.TERMINATION_REQUESTED + self._cond.notify() + self._cond.wait_for( + lambda: self._termination == self.__class__.TERMINATED) + + def get(self): + with self._cond: + self._cond.wait_for( + lambda: self._model_data is not None or self._termination) + + if self._termination: + if self._termination == self.__class__.TERMINATION_REQUESTED: + self._termination = self.__class__.TERMINATION_ACCEPTED + return None + + model_data = self._model_data + self._busy = True + self._model_data = None + return model_data + + def done(self): + with self._cond: + self._busy = False + if self._termination == self.__class__.TERMINATION_ACCEPTED: + self._termination = self.__class__.TERMINATED + self._cond.notify() + + +mujoco.viewer._MJPYTHON = _MjPythonImpl() +atexit.register(mujoco.viewer._MJPYTHON.terminate) +del _MjPythonImpl # Don't pollute globals for user script. + +with cond: + cond.notify() +del cond # Don't pollute globals for user script. +)", nullptr); + + // Run the Python interpreter main loop. +#if PY_MINOR_VERSION >= 8 + cpython.Py_RunMain(); +#else + cpython.Py_Main(args->argc, wargv); +#endif + + // Tear down the interpreter. + cpython.Py_FinalizeEx(); +#if PY_MINOR_VERSION < 8 + for (int i = 0; i < args->argc; ++i) { + cpython.PyMem_RawFree(wargv[i]); + wargv[i] = nullptr; + } + std::free(wargv); + wargv = nullptr; +#endif + return nullptr; +} +} // namespace + +int main(int argc, char** argv) { + const char* libpython_path = getenv("MJPYTHON_LIBPYTHON"); + if (!libpython_path || !libpython_path[0]) { + std::cerr << "This binary must be launched via the mjpython.py script.\n"; + return 1; + } + + // Resolve libpython at runtime to prevent linking against the wrong dylib. The correct libpython + // path is passed from a Python trampoline script, which ran inside the desired interpreter and + // exec'd this binary. + void* libpython = dlopen(libpython_path, RTLD_NOW | RTLD_GLOBAL); + + // Look up required CPython API functions from table of symbols already loaded into the process. +#define CPYTHON_INITFN(fname) \ + cpython.fname = reinterpret_cast(dlsym(libpython, #fname)) + +#if PY_MINOR_VERSION >= 8 + CPYTHON_INITFN(Py_InitializeFromConfig); + CPYTHON_INITFN(Py_RunMain); + CPYTHON_INITFN(PyConfig_Clear); + CPYTHON_INITFN(PyConfig_InitPythonConfig); + CPYTHON_INITFN(PyConfig_SetBytesArgv); +#else + CPYTHON_INITFN(Py_DecodeLocale); + CPYTHON_INITFN(Py_Initialize); + CPYTHON_INITFN(Py_Main); + CPYTHON_INITFN(PyMem_RawFree); + CPYTHON_INITFN(Py_SetProgramName); +#endif + + // go/keep-sorted start + CPYTHON_INITFN(Py_FinalizeEx); + CPYTHON_INITFN(PyGILState_Ensure); + CPYTHON_INITFN(PyGILState_Release); + CPYTHON_INITFN(PyRun_SimpleStringFlags); + // go/keep-sorted end + +#undef CPYTHON_INITFN + + // Package up argc and argv together to pass to pthread_create. + Args args{argc, argv}; + + // Create a thread to be used as the "Python main thread". + pthread_t pymain_thread = [&args]() { + // Set the stack size of the Python main thread to be the same as the OS main thread. + // (e.g. the default pthread stack size is too small to import NumPy) + rlimit limit; + getrlimit(RLIMIT_STACK, &limit); + + pthread_attr_t attr; + pthread_attr_init(&attr); + pthread_attr_setstacksize(&attr, limit.rlim_cur); + + pthread_t thread; + pthread_create(&thread, &attr, &mjpython_pymain, &args); + return thread; + }(); + + // Busy-wait until Python interpreter is initialized. + while (!py_initialized.load()) {} + + // Initialize GLFW on the macOS main thread, yield control to Python main thread and wait for it + // to finish setting up _MJPYTHON, then serve incoming viewer launch requests. + PyGILState_STATE gil = cpython.PyGILState_Ensure(); + cpython.PyRun_SimpleStringFlags(R"( +import ctypes + +# GLFW must be initialized on the OS main thread (i.e. here). +import glfw +import mujoco.viewer + +glfw.init() +glfw.poll_events() +ctypes.CDLL(None).mjpython_hide_dock_icon() + +# Wait for Python main thread to finish setting up _MJPYTHON +with cond: + cond.notify() + cond.wait() + +while True: + try: + # Wait for an incoming payload. + payload = mujoco.viewer._MJPYTHON.get() + + # None means that we are exiting. + if payload is None: + glfw.terminate() + break + + # Otherwise, launch the viewer. + model, data = payload + ctypes.CDLL(None).mjpython_show_dock_icon() + mujoco.viewer._launch_internal(model, data, run_physics_thread=False) + ctypes.CDLL(None).mjpython_hide_dock_icon() + + finally: + mujoco.viewer._MJPYTHON.done() +)", nullptr); + cpython.PyGILState_Release(gil); + + // Tear everything down. + pthread_join(pymain_thread, nullptr); + dlclose(libpython); +} diff --git a/python/mujoco/mjpython/mjpython.py b/python/mujoco/mjpython/mjpython.py new file mode 100644 index 00000000..8360804f --- /dev/null +++ b/python/mujoco/mjpython/mjpython.py @@ -0,0 +1,44 @@ +#!/usr/bin/env python +# Copyright 2023 DeepMind Technologies Limited +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ============================================================================== +"""Python interpreter trampoline for macOS to support non-block Cocoa GUI. + +This script executes a native binary that runs the CPython interpreter entry +point in a separate thread, thus leaving the macOS main thread free for Cocoa +GUI calls without blocking the user's Python script. In other words, Python's +idea of the "main thread" is different from the thread that holds the +com.apple.main-thread DispatchQueue. +""" + +import importlib.util +import os +import sys +import sysconfig + + +def main(argv): + os.environ['MJPYTHON_LIBPYTHON'] = os.path.join( + sysconfig.get_config_var('PYTHONFRAMEWORKPREFIX'), + sysconfig.get_config_var('INSTSONAME'), + ) + argv[0] = sys.executable + mujoco_dir = os.path.dirname(importlib.util.find_spec('mujoco').origin) + os.execve( + os.path.join(mujoco_dir, 'MuJoCo (mjpython).app/Contents/MacOS/mjpython'), + argv, os.environ) + + +if __name__ == '__main__': + main(sys.argv) diff --git a/python/mujoco/viewer.py b/python/mujoco/viewer.py index 89ab655b..9c00bd88 100644 --- a/python/mujoco/viewer.py +++ b/python/mujoco/viewer.py @@ -14,6 +14,7 @@ # ============================================================================== """Interactive GUI viewer for MuJoCo.""" +import abc import atexit import code import inspect @@ -58,6 +59,18 @@ _InternalLoaderType = Union[LoaderType, _LoaderWithPathType] Simulate = _simulate.Simulate +# Abstract base dispatcher class for systems that require UI calls to be made +# on a specific thread (e.g. macOS). This is subclassed by system-specific +# Python launcher (mjpython) to implement the required dispatching mechanism. +class _MjPythonBase(metaclass=abc.ABCMeta): + + def launch_on_ui_thread(self, model: mujoco.MjModel, data: mujoco.MjData): + pass + +# When running under mjpython, the launcher initializes this object. +_MJPYTHON: Optional[_MjPythonBase] = None + + def _file_loader(path: str) -> _LoaderWithPathType: """Loads an MJCF model from file path.""" @@ -238,11 +251,11 @@ def _launch_internal(model: Optional[mujoco.MjModel] = None, if simulate is None: simulate = Simulate() - # Initialize GLFW. - if not glfw.init(): - raise mujoco.FatalError('could not initialize GLFW') - - atexit.register(glfw.terminate) + # Initialize GLFW if not using mjpython. + if _MJPYTHON is None: + if not glfw.init(): + raise mujoco.FatalError('could not initialize GLFW') + atexit.register(glfw.terminate) side_thread = None if run_physics_thread: @@ -251,8 +264,18 @@ def _launch_internal(model: Optional[mujoco.MjModel] = None, else: side_thread = threading.Thread( target=_reload, args=(simulate, loader)) + + def make_exit_requester(simulate): + def exit_requester(): + simulate.exitrequest = True + return exit_requester + + exit_requester = make_exit_requester(simulate) + atexit.register(exit_requester) + side_thread.start() simulate.renderloop() + atexit.unregister(exit_requester) side_thread.join() @@ -271,6 +294,28 @@ def launch_from_path(path: str) -> None: _launch_internal(loader=_file_loader(path)) +def launch_passive(model: mujoco.MjModel, data: mujoco.MjData) -> None: + """Launches a passive Simulate GUI without blocking the running thread.""" + if not isinstance(model, mujoco.MjModel): + raise ValueError(f'`model` is not a mujoco.MjModel: got {model!r}') + if not isinstance(data, mujoco.MjData): + raise ValueError(f'`data` is not a mujoco.MjData: got {data!r}') + if sys.platform != 'darwin': + thread = threading.Thread( + target=_launch_internal, + args=(model, data), + kwargs=dict(run_physics_thread=False), + ) + thread.daemon = True + thread.start() + else: + if not isinstance(_MJPYTHON, _MjPythonBase): + raise RuntimeError( + '`launch_passive` requires that the Python script be run under ' + '`mjpython`') + _MJPYTHON.launch_on_ui_thread(model, data) + + def launch_repl(model: mujoco.MjModel, data: mujoco.MjData) -> None: """Launches the Simulate GUI in REPL mode.""" ipython_shell = None diff --git a/python/setup.py b/python/setup.py index 610b8581..656cf482 100644 --- a/python/setup.py +++ b/python/setup.py @@ -15,6 +15,7 @@ """Install script for MuJoCo.""" import fnmatch +import logging import os import platform import random @@ -29,6 +30,7 @@ import setuptools from setuptools import find_packages from setuptools import setup from setuptools.command import build_ext +from setuptools.command import install_scripts __version__ = '2.3.2' @@ -70,6 +72,7 @@ def get_external_lib_patterns(): else: return ['libmujoco.so.*'] + def get_plugin_lib_patterns(): if platform.system() == 'Windows': return ['*.dll'] @@ -159,6 +162,8 @@ class BuildCMakeExtension(build_ext.build_ext): self._copy_external_libraries() self._copy_mujoco_headers() self._copy_plugin_libraries() + if self._is_apple: + self._copy_mjpython() def _find_mujoco(self): if MUJOCO_PATH not in os.environ: @@ -213,6 +218,26 @@ class BuildCMakeExtension(build_ext.build_ext): shutil.copyfile(os.path.join(directory, filename), os.path.join(dst, filename)) + def _copy_mjpython(self): + src_dir = os.path.join(os.path.dirname(__file__), 'mujoco/mjpython') + dst_contents_dir = os.path.join( + os.path.dirname(self.get_ext_fullpath(self.extensions[0].name)), + 'MuJoCo (mjpython).app/Contents') + os.makedirs(dst_contents_dir) + shutil.copyfile(os.path.join(src_dir, 'Info.plist'), + os.path.join(dst_contents_dir, 'Info.plist')) + + dst_bin_dir = os.path.join(dst_contents_dir, 'MacOS') + os.makedirs(dst_bin_dir) + shutil.copyfile(os.path.join(self.build_temp, 'mjpython'), + os.path.join(dst_bin_dir, 'mjpython')) + os.chmod(os.path.join(dst_bin_dir, 'mjpython'), 0o755) + + dst_resources_dir = os.path.join(dst_contents_dir, 'Resources') + os.makedirs(dst_resources_dir) + shutil.copyfile(os.path.join(src_dir, 'mjpython.icns'), + os.path.join(dst_resources_dir, 'mjpython.icns')) + def _configure_cmake(self): """Check for CMake.""" cmake = os.environ.get(MUJOCO_CMAKE, 'cmake') @@ -276,6 +301,40 @@ class BuildCMakeExtension(build_ext.build_ext): build_path = os.path.join(self.build_temp, os.path.basename(dest_path)) shutil.copyfile(build_path, dest_path) + +class InstallScripts(install_scripts.install_scripts): + """Strips file extension from executable scripts whose names end in `.py`.""" + + def run(self): + super().run() + oldfiles = self.outfiles + files = set(oldfiles) + self.outfiles = [] + for oldfile in oldfiles: + if oldfile.endswith('.py'): + newfile = oldfile[:-3] + else: + newfile = oldfile + + renamed = False + if newfile not in files and not os.path.exists(newfile): + if not self.dry_run: + os.rename(oldfile, newfile) + renamed = True + + if renamed: + logging.info( + 'Renaming %s script to %s', + os.path.basename(oldfile), + os.path.basename(newfile), + ) + self.outfiles.append(newfile) + files.remove(oldfile) + files.add(newfile) + else: + self.outfiles.append(oldfile) + + def find_data_files(package_dir, patterns): """Recursively finds files whose names match the given shell patterns.""" paths = set() @@ -287,8 +346,7 @@ def find_data_files(package_dir, patterns): paths.add(os.path.join(relative_dirpath, filename)) return list(paths) - -setup( +SETUP_KWARGS = dict( name='mujoco', version=__version__, author='DeepMind', @@ -312,7 +370,10 @@ setup( 'Programming Language :: Python :: 3.11', 'Topic :: Scientific/Engineering', ], - cmdclass=dict(build_ext=BuildCMakeExtension), + cmdclass=dict( + build_ext=BuildCMakeExtension, + install_scripts=InstallScripts, + ), ext_modules=[ CMakeExtension('mujoco._callbacks'), CMakeExtension('mujoco._constants'), @@ -351,3 +412,8 @@ setup( ]), }, ) + +if platform.system() == 'Darwin': + SETUP_KWARGS['scripts'] = ['mujoco/mjpython/mjpython.py'] + +setup(**SETUP_KWARGS)