diff --git a/.readthedocs.yml b/.readthedocs.yml index 00c093bb..89ef662f 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -11,21 +11,37 @@ build: os: ubuntu-24.04 tools: python: "3.12" + apt_packages: + - libgl-dev jobs: create_environment: + # install uv - asdf plugin add uv - asdf install uv latest - asdf global uv latest - uv venv $READTHEDOCS_VIRTUALENV_PATH - - UV_PROJECT_ENVIRONMENT=$READTHEDOCS_VIRTUALENV_PATH uv pip install -r doc/requirements.txt - - UV_PROJECT_ENVIRONMENT=$READTHEDOCS_VIRTUALENV_PATH uv pip install mujoco mujoco-mjx - # replace mujoco.mjx.third_party.mujoco_warp import paths with mujoco_warp + # install doc requirements - | - find mjx/mujoco/mjx/third_party/mujoco_warp -type f -exec sed -i 's/mujoco\.mjx\.third_party\.mujoco_warp/mujoco_warp/g' {} \; + UV_PROJECT_ENVIRONMENT=$READTHEDOCS_VIRTUALENV_PATH \ + uv pip install \ + -r doc/requirements.txt \ + pip setuptools absl-py + # generate and install doc-only mujoco stubs (no C build required) + - python doc/make_mujoco_stubs.py python/mujoco_doc + - | + UV_PROJECT_ENVIRONMENT=$READTHEDOCS_VIRTUALENV_PATH \ + uv pip install --no-deps python/mujoco_doc + # install mjx and mujoco_warp (mujoco dep satisfied by stubs above) + - UV_PROJECT_ENVIRONMENT=$READTHEDOCS_VIRTUALENV_PATH uv pip install -e mjx + - | + find mjx/mujoco/mjx/third_party/mujoco_warp -type f -exec \ + sed -i 's/mujoco\.mjx\.third_party\.mujoco_warp/mujoco_warp/g' {} \; - python doc/mjwarp/update_types.py mjx/mujoco/mjx/third_party/mujoco_warp/_src/types.py - - UV_PROJECT_ENVIRONMENT=$READTHEDOCS_VIRTUALENV_PATH uv pip install mjx/mujoco/mjx/third_party/mujoco_warp + - | + UV_PROJECT_ENVIRONMENT=$READTHEDOCS_VIRTUALENV_PATH \ + uv pip install mjx/mujoco/mjx/third_party/mujoco_warp install: - - "true" # skip + - "true" # skip default install sphinx: builder: html diff --git a/doc/make_mujoco_stubs.py b/doc/make_mujoco_stubs.py new file mode 100644 index 00000000..be9dab13 --- /dev/null +++ b/doc/make_mujoco_stubs.py @@ -0,0 +1,178 @@ +# Copyright 2026 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. +# ============================================================================== +"""Generates a pip-installable doc-only mujoco stub package. + +This creates a lightweight pure-Python package that provides the mujoco +public API surface (enums, constants) without C extensions, for use by +Sphinx autodoc during documentation builds. + +Usage: + python doc/make_mujoco_stubs.py +""" + +import glob +import os +import re +import shutil +import sys +import textwrap +from typing import Dict, Sequence + + +def _read_version(repo_root: str) -> str: + """Reads the mujoco version from python/pyproject.toml.""" + with open(os.path.join(repo_root, 'python', 'pyproject.toml')) as f: + for line in f: + m = re.fullmatch(r'version\s*=\s*"([^"]+)"', line.strip()) + if m: + return m.group(1) + raise RuntimeError('Could not find version in python/pyproject.toml') + + +def _parse_constants(repo_root: str) -> Dict[str, str]: + """Parses numeric #define constants from MuJoCo C headers.""" + consts = {} + for path in glob.glob(os.path.join(repo_root, 'include', 'mujoco', '*.h')): + with open(path) as f: + for line in f: + # Match #define mjCONST 1.23 or 1.23f, capturing only the numeric part. + # Use (?:\s|$) to ensure we match the end of the token safely. + m = re.search( + r'^\s*#define\s+(mj[A-Z]\w*)\s+([\d.eE+\-]+)f?(?:\s|$)', line + ) + if m: + consts[m.group(1)] = m.group(2) + return consts + + +_PYPROJECT_TEMPLATE = textwrap.dedent("""\ + [build-system] + requires = ["setuptools"] + build-backend = "setuptools.build_meta" + + [project] + name = "mujoco" + version = "{version}" + requires-python = ">=3.10" + dependencies = [] + + [tool.setuptools] + include-package-data = false + + [tool.setuptools.packages.find] + include = ["mujoco*"] +""") + +_INIT_PY_TEMPLATE = textwrap.dedent("""\ + \"\"\"Doc-only stub for MuJoCo. Provides enums and constants for autodoc.\"\"\" + import enum + import sys + + __path__ = __import__('pkgutil').extend_path(__path__, __name__) + + from mujoco.introspect.enums import ENUMS + + _mod = sys.modules[__name__] + for _n, _d in ENUMS.items(): + _cls = enum.IntEnum(_n, list(_d.values.items())) + setattr(_mod, _n, _cls) + for _vn, _vv in _d.values.items(): + setattr(_mod, _vn, _vv) + + {constants} + + try: + from importlib.metadata import version as _v + __version__ = _v('mujoco') + except Exception: + __version__ = '0.0.0' + + def mj_versionString(): + return __version__ + + # Stub types for C extensions (MjModel, MjData, mj_* functions, etc.) + # needed by MJX and mujoco_warp imports during Sphinx autodoc. + # Each accessed name gets a dynamically created class so that Sphinx + # renders the real type name instead of "Mock". + _mock_cache = {{}} + + def _make_mock_meta(mock_name): + class _MockMeta(type): + def __getattr__(cls, name): + return _make_mock(f'{{mock_name}}.{{name}}') + def __instancecheck__(cls, instance): + return True + def __repr__(cls): + return mock_name + return _MockMeta + + def _make_mock(qualname): + if qualname in _mock_cache: + return _mock_cache[qualname] + basename = qualname.rsplit('.', 1)[-1] + meta = _make_mock_meta(qualname) + cls = meta(basename, (), {{ + '__init__': lambda self, *a, **kw: None, + '__call__': lambda self, *a, **kw: _make_mock(qualname)(), + '__getattr__': lambda self, name: _make_mock(f'{{qualname}}.{{name}}'), + '__class_getitem__': classmethod(lambda cls, item: cls), + '__iter__': lambda self: iter([]), + '__bool__': lambda self: False, + '__repr__': lambda self: qualname, + '__module__': 'mujoco', + '__qualname__': basename, + }}) + _mock_cache[qualname] = cls + return cls + + def __getattr__(name): + return _make_mock(name) +""") + + +def main(argv: Sequence[str]) -> None: + """Generates the doc-only mujoco stub package.""" + if len(argv) != 2: + print(f'Usage: {argv[0]} ', file=sys.stderr) + sys.exit(1) + + output_dir = argv[1] + repo_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + version = _read_version(repo_root) + consts = _parse_constants(repo_root) + + mujoco_dir = os.path.join(output_dir, 'mujoco') + os.makedirs(mujoco_dir, exist_ok=True) + + # Write pyproject.toml. + with open(os.path.join(output_dir, 'pyproject.toml'), 'w') as f: + f.write(_PYPROJECT_TEMPLATE.format(version=version)) + + # Write mujoco/__init__.py with constants inlined. + constants_str = '\n'.join(f'{k} = {v}' for k, v in sorted(consts.items())) + with open(os.path.join(mujoco_dir, '__init__.py'), 'w') as f: + f.write(_INIT_PY_TEMPLATE.format(constants=constants_str)) + + # Copy introspect/ into the package. + introspect_src = os.path.join(repo_root, 'python', 'mujoco', 'introspect') + introspect_dst = os.path.join(mujoco_dir, 'introspect') + shutil.rmtree(introspect_dst, ignore_errors=True) + shutil.copytree(introspect_src, introspect_dst) + + print(f'Generated doc-only mujoco {version} package at {output_dir}') + + +if __name__ == '__main__': + main(sys.argv)