Release docs auto-generation scripts and test
PiperOrigin-RevId: 950744854 Change-Id: Ibcd9d6bd3e6ec50d5d6753b8c9516f7d31f19e5b
This commit is contained in:
committed by
Copybara-Service
parent
78946ca94e
commit
1a33ca4ae5
Executable
+104
@@ -0,0 +1,104 @@
|
|||||||
|
# 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 API for APIReference.rst."""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from typing import Dict
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
_SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
_REPO_ROOT = os.path.dirname(os.path.dirname(_SCRIPT_DIR))
|
||||||
|
sys.path.insert(0, os.path.join(_REPO_ROOT, 'doc', 'ext'))
|
||||||
|
import header_reader
|
||||||
|
|
||||||
|
_HEADER_FILES = [
|
||||||
|
'include/mujoco/mjassert.h',
|
||||||
|
'include/mujoco/mjdata.h',
|
||||||
|
'include/mujoco/mjexport.h',
|
||||||
|
'include/mujoco/mjmacro.h',
|
||||||
|
'include/mujoco/mjmodel.h',
|
||||||
|
'include/mujoco/mjplugin.h',
|
||||||
|
'include/mujoco/mjrender.h',
|
||||||
|
'include/mujoco/mjrfilament.h',
|
||||||
|
'include/mujoco/mjspec.h',
|
||||||
|
'include/mujoco/mjspecmacro.h',
|
||||||
|
'include/mujoco/mjtype.h',
|
||||||
|
'include/mujoco/mjui.h',
|
||||||
|
'include/mujoco/mjvisualize.h',
|
||||||
|
'include/mujoco/mjxmacro.h',
|
||||||
|
'include/mujoco/mujoco.h',
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def generate_reference_header(
|
||||||
|
api: Dict[str, header_reader.ApiDefinition]) -> str:
|
||||||
|
"""Generates the reference header file used by APIRererence.rst."""
|
||||||
|
|
||||||
|
source = """
|
||||||
|
// Copyright 2022 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.
|
||||||
|
|
||||||
|
// DO NOT EDIT. THIS FILE IS AUTOMATICALLY GENERATED.
|
||||||
|
// Error: C reference not found
|
||||||
|
// NOLINTBEGIN\n\n""".lstrip()
|
||||||
|
|
||||||
|
for value in api.values():
|
||||||
|
if value.c_type != 'FUNCTION':
|
||||||
|
source = f'{source}{value.code}'
|
||||||
|
|
||||||
|
source = f"""{source}
|
||||||
|
//----------------------------- MJAPI FUNCTIONS --------------------------------
|
||||||
|
"""
|
||||||
|
|
||||||
|
for value in api.values():
|
||||||
|
if value.c_type == 'FUNCTION':
|
||||||
|
source = f'{source}{value.code}'
|
||||||
|
source = f'{source}// NOLINTEND\n'
|
||||||
|
return source
|
||||||
|
|
||||||
|
|
||||||
|
def read_headers() -> Dict[str, header_reader.ApiDefinition]:
|
||||||
|
"""Reads API header files and generates a mapping between C tokens and C header definitions."""
|
||||||
|
|
||||||
|
api = {}
|
||||||
|
|
||||||
|
for header in _HEADER_FILES:
|
||||||
|
filepath = os.path.join(_REPO_ROOT, header)
|
||||||
|
with open(filepath, 'r', encoding='utf-8') as file:
|
||||||
|
api.update(header_reader.read(file.readlines()))
|
||||||
|
return api
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
if len(sys.argv) > 1:
|
||||||
|
sys.exit('Too many command-line arguments.')
|
||||||
|
|
||||||
|
sys.stdout.buffer.write(generate_reference_header(read_headers()).encode('utf-8'))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
# 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 functions API for APIreference.rst."""
|
||||||
|
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
_SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
_REPO_ROOT = os.path.dirname(os.path.dirname(_SCRIPT_DIR))
|
||||||
|
sys.path.insert(0, _SCRIPT_DIR)
|
||||||
|
import generate_api_header
|
||||||
|
_FUNCTIONS_OVERRIDE = 'doc/APIreference/functions_override.rst'
|
||||||
|
|
||||||
|
_RST_SECTION_REGEX = re.compile(r'^\.\. (?P<section>_[A-Z].+):')
|
||||||
|
_RST_FUNCTION_REGEX = re.compile(r'^\.\. _(?P<function>mj.+):')
|
||||||
|
|
||||||
|
rst_to_section = {
|
||||||
|
'_Parseandcompile': ['Parse and compile', ''],
|
||||||
|
'_Mainsimulation': ['Main simulation', ''],
|
||||||
|
'_Support': ['Support', ''],
|
||||||
|
'_Components': ['Components', ''],
|
||||||
|
'_Subcomponents': ['Sub components', ''],
|
||||||
|
'_Raycollisions': ['Ray casting', ''],
|
||||||
|
'_Printing': ['Printing', ''],
|
||||||
|
'_Virtualfilesystem': ['Virtual file system', ''],
|
||||||
|
'_Assetcache': ['Asset cache', ''],
|
||||||
|
'_Resources': ['Resources', ''],
|
||||||
|
'_Initialization': ['Initialization', ''],
|
||||||
|
'_Errorandmemory': ['Error and memory', ''],
|
||||||
|
'_Miscellaneous': ['Miscellaneous', ''],
|
||||||
|
'_Interaction': ['Interaction', ''],
|
||||||
|
'_Visualization-api': ['Visualization', ''],
|
||||||
|
'_OpenGLrendering': ['OpenGL rendering', ''],
|
||||||
|
'_FilamentRenderingApi': ['Filament rendering', ''],
|
||||||
|
'_UIframework': ['UI framework', ''],
|
||||||
|
'_Derivatives-api': ['Derivatives', ''],
|
||||||
|
'_Signeddistancefunction': ['Signed Distance Functions', ''],
|
||||||
|
'_Plugins-api': ['Plugins', ''],
|
||||||
|
'_Thread': ['Threads', ''],
|
||||||
|
'_Standardmath': ['Standard math', ''],
|
||||||
|
'_Vectormath': ['Vector math', ''],
|
||||||
|
'_Sparsemath': ['Sparse math', ''],
|
||||||
|
'_Quaternions': ['Quaternions', ''],
|
||||||
|
'_Poses': ['Poses', ''],
|
||||||
|
'_Decompositions': ['Decompositions / Solvers', ''],
|
||||||
|
'_Attachment': ['Attachment', ''],
|
||||||
|
'_AddTreeElements': ['Tree elements', ''],
|
||||||
|
'_AddNonTreeElements': ['Non-tree elements', ''],
|
||||||
|
'_Setactuatorparameters': ['Set actuator parameters', ''],
|
||||||
|
'_AddAssets': ['Assets', ''],
|
||||||
|
'_FindAndGetUtilities': ['Find and get utilities', ''],
|
||||||
|
'_AttributeSetters': ['Attribute setters', ''],
|
||||||
|
'_AttributeGetters': ['Attribute getters', ''],
|
||||||
|
'_SpecUtilities': ['Spec utilities', ''],
|
||||||
|
'_ElementInitialization': ['Element initialization', ''],
|
||||||
|
'_ElementCasting': ['Element casting', ''],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def generate() -> str:
|
||||||
|
"""Generates functions.rst."""
|
||||||
|
rst_str = ''
|
||||||
|
api = generate_api_header.read_headers()
|
||||||
|
|
||||||
|
filepath = os.path.join(_REPO_ROOT, _FUNCTIONS_OVERRIDE)
|
||||||
|
with open(filepath, 'r', encoding='utf-8') as file:
|
||||||
|
current_section = None
|
||||||
|
current_function = None
|
||||||
|
for line in file:
|
||||||
|
|
||||||
|
# Try matching a section.
|
||||||
|
match = _RST_SECTION_REGEX.search(line)
|
||||||
|
if match is not None:
|
||||||
|
current_section = match.group('section')
|
||||||
|
if current_section in rst_to_section:
|
||||||
|
current_function = None
|
||||||
|
else:
|
||||||
|
current_section = None
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Try matching a function
|
||||||
|
match = _RST_FUNCTION_REGEX.search(line)
|
||||||
|
if match is not None:
|
||||||
|
current_function = match.group('function')
|
||||||
|
if current_function in api:
|
||||||
|
api[current_function].doc = ''
|
||||||
|
current_section = None
|
||||||
|
else:
|
||||||
|
current_function = None
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Update section doc.
|
||||||
|
if current_section is not None:
|
||||||
|
rst_to_section[current_section][1] += line
|
||||||
|
|
||||||
|
# Override doc.
|
||||||
|
if current_function is not None:
|
||||||
|
api[current_function].doc += line
|
||||||
|
|
||||||
|
# Check that all function sections from the headers are mapped.
|
||||||
|
mapped_sections = {v[0] for v in rst_to_section.values()}
|
||||||
|
for token, defn in api.items():
|
||||||
|
if defn.c_type == 'FUNCTION' and defn.section not in mapped_sections:
|
||||||
|
raise ValueError(
|
||||||
|
f'Function {token!r} is in unmapped section {defn.section!r}. '
|
||||||
|
f'Add an entry to rst_to_section in generate_functions.py.'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Write out RST file.
|
||||||
|
rst_str += ("""
|
||||||
|
..
|
||||||
|
AUTOGENERATED: DO NOT EDIT MANUALLY
|
||||||
|
|
||||||
|
|
||||||
|
""".lstrip())
|
||||||
|
|
||||||
|
for section in rst_to_section:
|
||||||
|
section_title = rst_to_section[section][0]
|
||||||
|
rst_str += (
|
||||||
|
f"""
|
||||||
|
.. {section}:
|
||||||
|
|
||||||
|
{section_title}
|
||||||
|
{'^'*len(section_title)}
|
||||||
|
{rst_to_section[section][1]}""".lstrip())
|
||||||
|
|
||||||
|
for token in api:
|
||||||
|
if api[token].c_type != 'FUNCTION' or api[token].section != section_title:
|
||||||
|
continue
|
||||||
|
|
||||||
|
doc = api[token].doc.strip()
|
||||||
|
doc = doc.replace(' ', '`` ``')
|
||||||
|
|
||||||
|
rst_str += (f"""
|
||||||
|
.. _{token}:
|
||||||
|
|
||||||
|
`{token} <#{token}>`__
|
||||||
|
{'~'*(2*len(token)+8)}
|
||||||
|
|
||||||
|
.. mujoco-include:: {token}
|
||||||
|
|
||||||
|
{doc}
|
||||||
|
|
||||||
|
""".lstrip())
|
||||||
|
return rst_str
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
if len(sys.argv) > 1:
|
||||||
|
sys.exit('Too many command-line arguments.')
|
||||||
|
|
||||||
|
sys.stdout.buffer.write(generate().encode('utf-8'))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
Executable
+112
@@ -0,0 +1,112 @@
|
|||||||
|
# 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.
|
||||||
|
# ==============================================================================
|
||||||
|
"""Script to automatically extract and insert MuJoCo source file paths."""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Extracts MuJoCo source file paths and updates linenumbers.js."""
|
||||||
|
parser = argparse.ArgumentParser(description='Update SRCS in linenumbers.js')
|
||||||
|
parser.add_argument(
|
||||||
|
'--src_dir', required=True, help='Path to mujoco src directory')
|
||||||
|
parser.add_argument(
|
||||||
|
'--js_file', required=True, help='Path to linenumbers.js file')
|
||||||
|
parser.add_argument(
|
||||||
|
'--ref_file', required=True, help='Path to references.h')
|
||||||
|
parser.add_argument(
|
||||||
|
'--check', action='store_true',
|
||||||
|
help='Check if SRCS matches, without updating')
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
with open(args.ref_file, 'r', encoding='utf-8') as f:
|
||||||
|
ref_content = f.read()
|
||||||
|
|
||||||
|
# Extract all mj_... symbols that might be function names
|
||||||
|
valid_funcs = set(re.findall(r'\bmj[a-zA-Z0-9_]*\b', ref_content))
|
||||||
|
|
||||||
|
srcs = []
|
||||||
|
pattern = re.compile(r'^(const )?[a-zA-Z0-9_*]+\s+(.+)\(.+[{,]$')
|
||||||
|
|
||||||
|
for root, _, files in os.walk(args.src_dir):
|
||||||
|
for f in files:
|
||||||
|
if f.endswith('.c') or f.endswith('.cc'):
|
||||||
|
path = os.path.relpath(os.path.join(root, f), args.src_dir)
|
||||||
|
# Ensure we use forward slashes for Javascript array
|
||||||
|
path = path.replace('\\', '/')
|
||||||
|
|
||||||
|
# Check if it has any function defined in references.h
|
||||||
|
filepath = os.path.join(root, f)
|
||||||
|
found = False
|
||||||
|
with open(filepath, 'r', encoding='utf-8') as cf:
|
||||||
|
for line in cf:
|
||||||
|
line = line.strip('\n')
|
||||||
|
match = pattern.match(line)
|
||||||
|
key = None
|
||||||
|
if match:
|
||||||
|
key = match.group(2).strip()
|
||||||
|
|
||||||
|
# edge cases
|
||||||
|
if 'user_api.cc' in filepath and line.startswith(
|
||||||
|
'[[nodiscard]] int mj_recompile('):
|
||||||
|
key = 'mj_recompile'
|
||||||
|
elif 'engine_io.c' in filepath:
|
||||||
|
if line.startswith('void mj_freeStack('):
|
||||||
|
key = 'mj_freeStack'
|
||||||
|
elif line.startswith('void mj_markStack('):
|
||||||
|
key = 'mj_markStack'
|
||||||
|
|
||||||
|
if key and key in valid_funcs:
|
||||||
|
found = True
|
||||||
|
break
|
||||||
|
|
||||||
|
if found:
|
||||||
|
srcs.append(path)
|
||||||
|
|
||||||
|
srcs = sorted(srcs)
|
||||||
|
|
||||||
|
with open(args.js_file, 'r', encoding='utf-8') as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
# Find the const SRCS = [ ... ]; block
|
||||||
|
js_pattern = re.compile(r'const SRCS = \[\n(.*?)\n\];', re.DOTALL)
|
||||||
|
match = js_pattern.search(content)
|
||||||
|
if not match:
|
||||||
|
sys.exit('Could not find const SRCS = [ in linenumbers.js')
|
||||||
|
|
||||||
|
current_srcs_str = match.group(1)
|
||||||
|
|
||||||
|
# Generate the new string
|
||||||
|
new_srcs_str = '\n'.join([f" '{s}'," for s in srcs])
|
||||||
|
|
||||||
|
if current_srcs_str == new_srcs_str:
|
||||||
|
print('SRCS is up to date.')
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
if args.check:
|
||||||
|
print('SRCS is not up to date. Please run update_docs to update it.')
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Otherwise, update the file
|
||||||
|
new_content = content[:match.start(1)] + new_srcs_str + content[match.end(1):]
|
||||||
|
with open(args.js_file, 'w', encoding='utf-8') as f:
|
||||||
|
f.write(new_content)
|
||||||
|
print(f'Updated SRCS in {args.js_file} with {len(srcs)} files.')
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
# 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 MJCF schema using dropdown directives for XMLreference.rst.
|
||||||
|
|
||||||
|
Generates XMLschema.rst by reading xml_native_reader.cc and producing
|
||||||
|
nested dropdown directives with list-table for attributes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import copy
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
# Map symbols to icons:
|
||||||
|
# ! = required element, can appear only once -> star (prominent, required)
|
||||||
|
# ? = optional element, can appear only once -> dot (minimal, single)
|
||||||
|
# * = optional element, can appear many times -> None (no icon, most common)
|
||||||
|
# R = optional element, can appear many times recursively -> sync (recursive)
|
||||||
|
SYMBOL_TO_ICON = {
|
||||||
|
'!': 'star',
|
||||||
|
'?': 'dot',
|
||||||
|
'*': None,
|
||||||
|
'R': 'sync',
|
||||||
|
}
|
||||||
|
|
||||||
|
ELEMENT_ORDER = [
|
||||||
|
'mujoco',
|
||||||
|
'option',
|
||||||
|
'compiler',
|
||||||
|
'size',
|
||||||
|
'statistic',
|
||||||
|
'asset',
|
||||||
|
'body',
|
||||||
|
'deformable',
|
||||||
|
'contact',
|
||||||
|
'equality',
|
||||||
|
'tendon',
|
||||||
|
'actuator',
|
||||||
|
'sensor',
|
||||||
|
'keyframe',
|
||||||
|
'visual',
|
||||||
|
'default',
|
||||||
|
'custom',
|
||||||
|
'extension',
|
||||||
|
]
|
||||||
|
|
||||||
|
# Special display names for elements (when different from the element name)
|
||||||
|
ELEMENT_DISPLAY_NAME = {
|
||||||
|
'body': '(world)body',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def generate_dropdown(
|
||||||
|
element_name: str,
|
||||||
|
symbol: str,
|
||||||
|
link_name: str,
|
||||||
|
attributes: list[str],
|
||||||
|
links: list[str],
|
||||||
|
level: int,
|
||||||
|
is_top_level: bool = False,
|
||||||
|
) -> str:
|
||||||
|
"""Generate a dropdown directive for an element with its attributes."""
|
||||||
|
indent = ' ' * level
|
||||||
|
output = ''
|
||||||
|
|
||||||
|
display_name = ELEMENT_DISPLAY_NAME.get(element_name, element_name)
|
||||||
|
icon = SYMBOL_TO_ICON.get(symbol)
|
||||||
|
# Element name is a :ref: link. Icon/macro goes on the right side.
|
||||||
|
# Use |*| for * elements (even though it's empty) for future flexibility.
|
||||||
|
element_link = f':ref:`{display_name}<{link_name}>`'
|
||||||
|
if icon:
|
||||||
|
title = f'{element_link} :octicon:`{icon}`'
|
||||||
|
else:
|
||||||
|
title = f'{element_link} |*|'
|
||||||
|
output += f'{indent}.. dropdown:: {title}\n'
|
||||||
|
if is_top_level:
|
||||||
|
output += f'{indent} :open:\n'
|
||||||
|
output += '\n'
|
||||||
|
|
||||||
|
content_indent = indent + ' '
|
||||||
|
|
||||||
|
# Responsive grid for attributes (2-3-4-4: mobile-tablet-desktop-large)
|
||||||
|
if attributes:
|
||||||
|
output += f'{content_indent}.. grid:: 2 3 4 4\n'
|
||||||
|
output += f'{content_indent} :gutter: 0\n'
|
||||||
|
output += '\n'
|
||||||
|
|
||||||
|
for attr in attributes:
|
||||||
|
att_link_name = f'{link_name}-{attr}'
|
||||||
|
if att_link_name not in links:
|
||||||
|
raise ValueError(
|
||||||
|
f'Link for attribute {att_link_name} not found, update'
|
||||||
|
' XMLreference.rst'
|
||||||
|
)
|
||||||
|
output += f'{content_indent} .. grid-item::\n'
|
||||||
|
output += f'{content_indent} :ref:`{attr}<{att_link_name}>`\n'
|
||||||
|
output += '\n'
|
||||||
|
|
||||||
|
output += '\n'
|
||||||
|
return output
|
||||||
|
|
||||||
|
|
||||||
|
def generate() -> str:
|
||||||
|
"""Generates XMLschema.rst by parsing xml_native_reader.cc.
|
||||||
|
|
||||||
|
The schema is defined in xml_native_reader.cc as a nested structure called
|
||||||
|
MJCF[nMJCF]. This function parses that structure and generates nested
|
||||||
|
dropdown directives with list-tables for attributes.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
RST content with nested dropdown directives for the MJCF schema.
|
||||||
|
"""
|
||||||
|
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
repo_root = os.path.dirname(os.path.dirname(script_dir))
|
||||||
|
filepath = os.path.join(repo_root, 'src', 'xml', 'xml_native_reader.cc')
|
||||||
|
xmlfile = os.path.join(repo_root, 'doc', 'XMLreference.rst')
|
||||||
|
|
||||||
|
# Collect all link targets from XMLreference.rst for validation.
|
||||||
|
links = []
|
||||||
|
with open(xmlfile, 'r', encoding='utf-8') as file:
|
||||||
|
for line in file:
|
||||||
|
if line.startswith('.. _'):
|
||||||
|
links.append(line.strip()[4:-1])
|
||||||
|
|
||||||
|
output = """..
|
||||||
|
DO NOT EDIT. THIS FILE IS AUTOMATICALLY GENERATED.
|
||||||
|
|
||||||
|
.. raw:: html
|
||||||
|
|
||||||
|
<div class="schema-controls" style="margin-bottom: 1em;">
|
||||||
|
<button onclick="document.querySelectorAll('.schema-small details').forEach(d => d.open = true)" class="sd-btn sd-btn-outline-primary sd-btn-sm">Expand All</button>
|
||||||
|
<button onclick="document.querySelectorAll('.schema-small details details').forEach(d => d.open = false)" class="sd-btn sd-btn-outline-secondary sd-btn-sm" style="margin-left: 0.5em;">Collapse All</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
with open(filepath, 'r', encoding='utf-8') as file:
|
||||||
|
to_strip = {' ', '{', '}', '"'}
|
||||||
|
|
||||||
|
# Process each top-level element in a specific order for consistent output.
|
||||||
|
for top_element in ELEMENT_ORDER:
|
||||||
|
level = 0
|
||||||
|
element: list[str] = []
|
||||||
|
parent = ['', '', '', '', ''] # Track parent elements for link names.
|
||||||
|
found_element = False
|
||||||
|
file.seek(0)
|
||||||
|
|
||||||
|
# Skip to the MJCF schema definition in the C++ source.
|
||||||
|
for line in file:
|
||||||
|
if 'std::vector<const char*> MJCF[nMJCF] = {' in line.strip():
|
||||||
|
break
|
||||||
|
|
||||||
|
# Parse the schema structure.
|
||||||
|
for line in file:
|
||||||
|
if line.strip().endswith('};'):
|
||||||
|
break # End of schema definition.
|
||||||
|
|
||||||
|
# Track nesting level using angle brackets in the C++ source.
|
||||||
|
if '<' in line:
|
||||||
|
level += 1
|
||||||
|
continue
|
||||||
|
if '>' in line:
|
||||||
|
level -= 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Parse element definition from the line.
|
||||||
|
line_is_done = 1 if '}' in line else 0
|
||||||
|
line_copy = copy.copy(line)
|
||||||
|
for item in to_strip:
|
||||||
|
line_copy = line_copy.replace(item, '')
|
||||||
|
element += [item for item in line_copy.split(',') if item != '\n']
|
||||||
|
|
||||||
|
# Determine if this element belongs to the current top-level element.
|
||||||
|
outer_level = level == 0 and top_element == 'mujoco'
|
||||||
|
top_level = level == 1 and element and top_element == element[0]
|
||||||
|
sub_level = level > 1 and top_element == parent[2]
|
||||||
|
level_is_correct = outer_level or top_level or sub_level
|
||||||
|
|
||||||
|
if found_element and not level_is_correct:
|
||||||
|
break # Done with this top-level element.
|
||||||
|
found_element = level_is_correct
|
||||||
|
|
||||||
|
# Generate dropdown for completed element definition.
|
||||||
|
if line_is_done and level_is_correct:
|
||||||
|
# Link name: top-level uses element name, nested uses parent-child.
|
||||||
|
link_name = element[0]
|
||||||
|
if level > 1:
|
||||||
|
link_name = parent[level] + '-' + element[0]
|
||||||
|
if link_name not in links:
|
||||||
|
raise ValueError(
|
||||||
|
f'Link for element {link_name} not found, update'
|
||||||
|
' XMLreference.rst'
|
||||||
|
)
|
||||||
|
attributes = element[2:] # First two items are name and symbol.
|
||||||
|
|
||||||
|
# Adjust indentation level for RST output.
|
||||||
|
dropdown_level = 0 if level == 0 else (1 if level == 1 else level)
|
||||||
|
is_top_level = level == 0
|
||||||
|
output += generate_dropdown(
|
||||||
|
element[0],
|
||||||
|
element[1],
|
||||||
|
link_name,
|
||||||
|
attributes,
|
||||||
|
links,
|
||||||
|
dropdown_level,
|
||||||
|
is_top_level,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Track parent elements for building nested link names.
|
||||||
|
if line_is_done:
|
||||||
|
if level < 4:
|
||||||
|
parent[level + 1] = element[0]
|
||||||
|
element = []
|
||||||
|
|
||||||
|
return output
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
if len(sys.argv) > 1:
|
||||||
|
sys.exit('Too many command-line arguments.')
|
||||||
|
sys.stdout.buffer.write(generate().encode('utf-8'))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
+1
-1
@@ -126,4 +126,4 @@ add_subdirectory(xml)
|
|||||||
add_subdirectory(plugin/elasticity)
|
add_subdirectory(plugin/elasticity)
|
||||||
add_subdirectory(plugin/actuator)
|
add_subdirectory(plugin/actuator)
|
||||||
add_subdirectory(experimental)
|
add_subdirectory(experimental)
|
||||||
|
add_subdirectory(doc)
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# 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
|
||||||
|
#
|
||||||
|
# https://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.
|
||||||
|
|
||||||
|
find_package(Python3 COMPONENTS Interpreter)
|
||||||
|
if(Python3_FOUND)
|
||||||
|
add_test(NAME doc_test
|
||||||
|
COMMAND Python3::Interpreter ${CMAKE_CURRENT_SOURCE_DIR}/doc_test.py
|
||||||
|
WORKING_DIRECTORY ${CMAKE_SOURCE_DIR})
|
||||||
|
endif()
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
# 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.
|
||||||
|
# ==============================================================================
|
||||||
|
"""Tests that the API reference documentation is complete and up to date."""
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import unittest as googletest
|
||||||
|
_SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
_REPO_ROOT = os.path.dirname(os.path.dirname(_SCRIPT_DIR))
|
||||||
|
sys.path.insert(0, os.path.join(_REPO_ROOT, 'doc', 'generate'))
|
||||||
|
import generate_api_header
|
||||||
|
import generate_functions
|
||||||
|
import generate_schema
|
||||||
|
|
||||||
|
# Functions in headers that are intentionally not in functions.rst.
|
||||||
|
_FUNCTIONS_TO_SKIP = set()
|
||||||
|
|
||||||
|
# Types (STRUCT/ENUM) in headers that are intentionally not in APItypes.rst.
|
||||||
|
_TYPES_TO_SKIP = set()
|
||||||
|
|
||||||
|
# Type names documented in APItypes.rst that aren't STRUCT/ENUM in headers.
|
||||||
|
# These are typedefs, callbacks, and scalar types.
|
||||||
|
_EXTRA_DOCUMENTED_TYPES = {
|
||||||
|
# scalar typedefs
|
||||||
|
'mjtByte',
|
||||||
|
'mjtBool',
|
||||||
|
'mjtNum',
|
||||||
|
'mjtSize',
|
||||||
|
# C++ type aliases
|
||||||
|
'mjByteVec',
|
||||||
|
'mjDoubleVec',
|
||||||
|
'mjFloatVec',
|
||||||
|
'mjFloatVecVec',
|
||||||
|
'mjIntVec',
|
||||||
|
'mjIntVecVec',
|
||||||
|
'mjString',
|
||||||
|
'mjStringVec',
|
||||||
|
# function pointer typedefs (callbacks)
|
||||||
|
'mjfAct',
|
||||||
|
'mjfCanDecode',
|
||||||
|
'mjfCloseResource',
|
||||||
|
'mjfCollision',
|
||||||
|
'mjfConFilt',
|
||||||
|
'mjfDecode',
|
||||||
|
'mjfEncode',
|
||||||
|
'mjfGeneric',
|
||||||
|
'mjfGetResourceDir',
|
||||||
|
'mjfItemEnable',
|
||||||
|
'mjfLogHandler',
|
||||||
|
'mjfOpenResource',
|
||||||
|
'mjfReadResource',
|
||||||
|
'mjfResourceModified',
|
||||||
|
'mjfSensor',
|
||||||
|
'mjfTime',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class DocTest(googletest.TestCase):
|
||||||
|
|
||||||
|
def test_api_header(self):
|
||||||
|
"""Checks that references.h matches the generated output."""
|
||||||
|
header_file = os.path.join(_REPO_ROOT, 'doc', 'includes', 'references.h')
|
||||||
|
source = generate_api_header.generate_reference_header(
|
||||||
|
generate_api_header.read_headers()
|
||||||
|
)
|
||||||
|
with open(header_file, 'r', encoding='utf-8') as file:
|
||||||
|
if source != file.read():
|
||||||
|
self.fail("The file 'references.h' needs to be updated.")
|
||||||
|
|
||||||
|
def test_schema(self):
|
||||||
|
"""Checks that XMLschema.rst matches the generated output."""
|
||||||
|
schema_file = os.path.join(_REPO_ROOT, 'doc', 'XMLschema.rst')
|
||||||
|
source = generate_schema.generate()
|
||||||
|
with open(schema_file, 'r', encoding='utf-8') as file:
|
||||||
|
if source != file.read():
|
||||||
|
self.fail("The file 'XMLschema.rst' needs to be updated.")
|
||||||
|
|
||||||
|
def test_functions(self):
|
||||||
|
"""Checks that functions.rst matches the generated output."""
|
||||||
|
functions_file = os.path.join(_REPO_ROOT, 'doc', 'APIreference', 'functions.rst')
|
||||||
|
source = generate_functions.generate()
|
||||||
|
with open(functions_file, 'r', encoding='utf-8') as file:
|
||||||
|
if source != file.read():
|
||||||
|
self.fail("The file 'functions.rst' needs to be updated.")
|
||||||
|
|
||||||
|
def test_all_functions_included(self):
|
||||||
|
"""Checks that every public C function has an entry in functions.rst."""
|
||||||
|
|
||||||
|
functions_file = os.path.join(_REPO_ROOT, 'doc', 'APIreference', 'functions.rst')
|
||||||
|
with open(functions_file, 'r', encoding='utf-8') as file:
|
||||||
|
content = file.read()
|
||||||
|
|
||||||
|
documented = set(
|
||||||
|
re.findall(r'^\.\. _(mj[a-zA-Z0-9_]+):', content, flags=re.MULTILINE)
|
||||||
|
)
|
||||||
|
|
||||||
|
api = generate_api_header.read_headers()
|
||||||
|
header_funcs = {token for token, d in api.items() if d.c_type == 'FUNCTION'}
|
||||||
|
|
||||||
|
errors = []
|
||||||
|
for token in sorted(header_funcs - documented - _FUNCTIONS_TO_SKIP):
|
||||||
|
d = api[token]
|
||||||
|
errors.append(f' undocumented: {token} (section: {d.section!r})')
|
||||||
|
for token in sorted(documented - header_funcs):
|
||||||
|
errors.append(f' stale: {token} (in functions.rst but not in headers)')
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
msg = 'functions.rst mismatches:\n' + '\n'.join(errors)
|
||||||
|
self.fail(msg)
|
||||||
|
|
||||||
|
def test_all_types_included(self):
|
||||||
|
"""Checks that every public struct and enum has an entry in APItypes.rst."""
|
||||||
|
|
||||||
|
types_file = os.path.join(_REPO_ROOT, 'doc', 'APIreference', 'APItypes.rst')
|
||||||
|
with open(types_file, 'r', encoding='utf-8') as file:
|
||||||
|
content = file.read()
|
||||||
|
|
||||||
|
documented = set(
|
||||||
|
re.findall(r'^\.\. _(mj[a-zA-Z0-9_]+):', content, flags=re.MULTILINE)
|
||||||
|
)
|
||||||
|
|
||||||
|
api = generate_api_header.read_headers()
|
||||||
|
header_types = {
|
||||||
|
token for token, d in api.items() if d.c_type in ('STRUCT', 'ENUM')
|
||||||
|
}
|
||||||
|
|
||||||
|
errors = []
|
||||||
|
for token in sorted(header_types - documented - _TYPES_TO_SKIP):
|
||||||
|
d = api[token]
|
||||||
|
errors.append(
|
||||||
|
f' undocumented: {token} ({d.c_type}, section: {d.section!r})'
|
||||||
|
)
|
||||||
|
for token in sorted(documented - header_types - _EXTRA_DOCUMENTED_TYPES):
|
||||||
|
errors.append(f' stale: {token} (in APItypes.rst but not in headers)')
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
msg = 'APItypes.rst mismatches:\n' + '\n'.join(errors)
|
||||||
|
self.fail(msg)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
googletest.main()
|
||||||
Reference in New Issue
Block a user