|
1 | | -#!/usr/bin/env python3 |
2 | | -""" |
3 | | -MuJoCo Viewer Server Launcher |
4 | | -Simplified viewer server for package distribution |
| 1 | +"""MuJoCo Viewer Server access helpers. |
| 2 | +
|
| 3 | +This module exposes the interactive viewer server class used by integration and |
| 4 | +RL tests while keeping a simple CLI entry point for package builds. The actual |
| 5 | +implementation lives in the top-level ``mujoco_viewer_server`` module so that it |
| 6 | +can be launched both from source checkouts and from installed environments. |
5 | 7 | """ |
6 | 8 |
|
7 | | -import sys |
8 | | -import subprocess |
| 9 | +from __future__ import annotations |
| 10 | + |
| 11 | +import importlib |
9 | 12 | import logging |
| 13 | +import subprocess |
| 14 | +import sys |
10 | 15 | from pathlib import Path |
| 16 | +from types import ModuleType |
| 17 | +from typing import Optional |
11 | 18 |
|
12 | | -def main(): |
13 | | - """Main entry point for viewer server""" |
14 | | - logging.basicConfig(level=logging.INFO) |
15 | | - logger = logging.getLogger("mujoco-mcp-viewer") |
| 19 | +_LOGGER = logging.getLogger("mujoco_mcp.viewer_server") |
16 | 20 |
|
17 | | - # Find the viewer server script |
18 | | - script_path = Path(__file__).parent.parent.parent / "mujoco_viewer_server.py" |
19 | 21 |
|
20 | | - if not script_path.exists(): |
21 | | - # Try relative to current working directory |
22 | | - script_path = Path("mujoco_viewer_server.py") |
| 22 | +def _load_viewer_module() -> ModuleType: |
| 23 | + """Return the runtime viewer server module, importing it on demand. |
23 | 24 |
|
24 | | - if not script_path.exists(): |
25 | | - logger.error("Could not find mujoco_viewer_server.py") |
26 | | - logger.error("Please run from the mujoco-mcp directory or ensure the viewer server is in your PATH") |
27 | | - sys.exit(1) |
| 25 | + The viewer server lives at the repository root (``mujoco_viewer_server.py``). |
| 26 | + When the package is installed in editable mode this file sits alongside the |
| 27 | + package sources, so we extend ``sys.path`` with the project root before |
| 28 | + importing. If the module cannot be imported we surface a helpful error message |
| 29 | + instead of failing silently. |
| 30 | + """ |
28 | 31 |
|
29 | | - logger.info(f"Starting MuJoCo Viewer Server from {script_path}") |
| 32 | + root = Path(__file__).resolve().parents[2] |
| 33 | + if str(root) not in sys.path: |
| 34 | + sys.path.append(str(root)) |
30 | 35 |
|
31 | 36 | try: |
32 | | - # Launch the viewer server |
33 | | - subprocess.run([sys.executable, str(script_path)], check=True) |
34 | | - except KeyboardInterrupt: |
35 | | - logger.info("Viewer server stopped by user") |
36 | | - except subprocess.CalledProcessError as e: |
37 | | - logger.exception(f"Viewer server failed: {e}") |
38 | | - sys.exit(1) |
39 | | - except Exception as e: |
40 | | - logger.exception(f"Unexpected error: {e}") |
41 | | - sys.exit(1) |
42 | | - |
43 | | -if __name__ == "__main__": |
44 | | - main() |
| 37 | + return importlib.import_module("mujoco_viewer_server") |
| 38 | + except Exception as exc: # pragma: no cover - executed only when missing deps |
| 39 | + raise ImportError( |
| 40 | + "Unable to import mujoco_viewer_server. Ensure the viewer server script " |
| 41 | + "is available in the project root or installed alongside the package" |
| 42 | + ) from exc |
| 43 | + |
| 44 | + |
| 45 | +def get_viewer_class() -> type: |
| 46 | + """Fetch ``MuJoCoViewerServer`` from the runtime module.""" |
| 47 | + |
| 48 | + module = _load_viewer_module() |
| 49 | + try: |
| 50 | + return getattr(module, "MuJoCoViewerServer") |
| 51 | + except AttributeError as exc: # pragma: no cover - defensive guard |
| 52 | + raise ImportError( |
| 53 | + "mujoco_viewer_server does not define MuJoCoViewerServer" |
| 54 | + ) from exc |
| 55 | + |
| 56 | + |
| 57 | +class MuJoCoViewerServer(get_viewer_class()): # type: ignore[misc] |
| 58 | + """Proxy subclass so imports continue to work unchanged. |
| 59 | +
|
| 60 | + ``get_viewer_class`` is evaluated at import time and returns the concrete |
| 61 | + implementation. Subclassing keeps backward compatibility for user code that |
| 62 | + expects ``mujoco_mcp.viewer_server.MuJoCoViewerServer`` to be instantiable. |
| 63 | + """ |
| 64 | + |
| 65 | + pass |
| 66 | + |
| 67 | + |
| 68 | +def _resolve_script_path() -> Path: |
| 69 | + """Locate the standalone viewer server script used by the CLI.""" |
| 70 | + |
| 71 | + module = _load_viewer_module() |
| 72 | + path = Path(getattr(module, "__file__", "")) |
| 73 | + if not path: |
| 74 | + raise FileNotFoundError("Unable to resolve mujoco_viewer_server.py path") |
| 75 | + return path |
| 76 | + |
| 77 | + |
| 78 | +def main(argv: Optional[list[str]] = None) -> int: |
| 79 | + """CLI entry point that spawns the viewer server in a child process.""" |
| 80 | + |
| 81 | + logging.basicConfig(level=logging.INFO) |
| 82 | + script_path = _resolve_script_path() |
| 83 | + cmd = [sys.executable, str(script_path)] |
| 84 | + |
| 85 | + try: |
| 86 | + completed = subprocess.run(cmd, check=True) |
| 87 | + return completed.returncode |
| 88 | + except subprocess.CalledProcessError as exc: |
| 89 | + _LOGGER.exception("Viewer server exited with error") |
| 90 | + return exc.returncode |
| 91 | + except KeyboardInterrupt: # pragma: no cover - interactive convenience |
| 92 | + _LOGGER.info("Viewer server interrupted by user") |
| 93 | + return 130 |
| 94 | + except Exception as exc: # pragma: no cover - defensive |
| 95 | + _LOGGER.exception("Unexpected viewer server failure: %s", exc) |
| 96 | + return 1 |
| 97 | + |
| 98 | + |
| 99 | +if __name__ == "__main__": # pragma: no cover - CLI shim |
| 100 | + sys.exit(main()) |
0 commit comments