Skip to content

Commit 7ba8c93

Browse files
Add pybind stub for native bindings (#15)
* Python MANIFEST.in: add missing schema related so/pyd files * Generation pyi stub files for pybind classes * README.md: Call out --parallel for cmake build * fix windows build * Fix Python test directory path in Ubuntu build workflow * Skip stub generation on incremental builds --------- Co-authored-by: Andrei Aristarkhov <aaristarkhov@nvidia.com>
1 parent 264923f commit 7ba8c93

File tree

10 files changed

+192
-28
lines changed

10 files changed

+192
-28
lines changed

.github/workflows/build-ubuntu.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,6 @@ jobs:
4242
- name: Run Python Tests
4343
run: |
4444
uv run \
45-
--directory build/python_package \
45+
--directory build/python_package/${{ matrix.build_type }} \
4646
--extra dev \
47-
pytest ../../src/core -v --tb=short
47+
pytest ../../../src/core -v --tb=short

README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -71,8 +71,8 @@ git clone git@github.com:nvidia-isaac/TeleopCore.git
7171
cd TeleopCore
7272
```
7373

74-
> **Note**: Dependencies (OpenXR SDK, pybind11, yaml-cpp) are automatically downloaded
75-
> during CMake configuration using FetchContent. No manual dependency installation or
74+
> **Note**: Dependencies (OpenXR SDK, pybind11, yaml-cpp) are automatically downloaded
75+
> during CMake configuration using FetchContent. No manual dependency installation or
7676
> git submodule initialization is required.
7777
7878
4. **Download CloudXR**
@@ -164,7 +164,7 @@ Build with default settings. See [BUILD.md](BUILD.md) for advanced instructions
164164
for advanced build steps.
165165
```bash
166166
cmake -B build
167-
cmake --build build
167+
cmake --build build --parallel
168168
cmake --install build
169169
```
170170

src/core/oxr/python/CMakeLists.txt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ target_link_libraries(oxr_py
2626

2727
set_target_properties(oxr_py PROPERTIES
2828
OUTPUT_NAME "_oxr"
29-
LIBRARY_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/python_package/teleopcore/oxr"
29+
# Use genex in output directory - CMake won't add another config subdirectory
30+
LIBRARY_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/python_package/$<CONFIG>/teleopcore/oxr"
3031
)
3132

src/core/plugin_manager/python/CMakeLists.txt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ target_link_libraries(plugin_manager_py
2626

2727
set_target_properties(plugin_manager_py PROPERTIES
2828
OUTPUT_NAME "_plugin_manager"
29-
LIBRARY_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/python_package/teleopcore/plugin_manager"
29+
# Use genex in output directory - CMake won't add another config subdirectory
30+
LIBRARY_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/python_package/$<CONFIG>/teleopcore/plugin_manager"
3031
)
3132

src/core/python/CMakeLists.txt

Lines changed: 48 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,29 +5,63 @@
55

66
# After building modules, create the Python package structure.
77
add_custom_target(python_package ALL
8-
COMMAND ${CMAKE_COMMAND} -E make_directory "${CMAKE_BINARY_DIR}/python_package/teleopcore"
9-
COMMAND ${CMAKE_COMMAND} -E make_directory "${CMAKE_BINARY_DIR}/python_package/teleopcore/xrio"
10-
COMMAND ${CMAKE_COMMAND} -E make_directory "${CMAKE_BINARY_DIR}/python_package/teleopcore/oxr"
11-
COMMAND ${CMAKE_COMMAND} -E make_directory "${CMAKE_BINARY_DIR}/python_package/teleopcore/plugin_manager"
12-
COMMAND ${CMAKE_COMMAND} -E make_directory "${CMAKE_BINARY_DIR}/python_package/teleopcore/schema"
13-
COMMAND ${CMAKE_COMMAND} -E copy "${CMAKE_CURRENT_SOURCE_DIR}/../xrio/python/xrio_init.py" "${CMAKE_BINARY_DIR}/python_package/teleopcore/xrio/__init__.py"
14-
COMMAND ${CMAKE_COMMAND} -E copy "${CMAKE_CURRENT_SOURCE_DIR}/../oxr/python/oxr_init.py" "${CMAKE_BINARY_DIR}/python_package/teleopcore/oxr/__init__.py"
15-
COMMAND ${CMAKE_COMMAND} -E copy "${CMAKE_CURRENT_SOURCE_DIR}/../plugin_manager/python/plugin_manager_init.py" "${CMAKE_BINARY_DIR}/python_package/teleopcore/plugin_manager/__init__.py"
16-
COMMAND ${CMAKE_COMMAND} -E copy "${CMAKE_CURRENT_SOURCE_DIR}/../schema/python/schema_init.py" "${CMAKE_BINARY_DIR}/python_package/teleopcore/schema/__init__.py"
17-
COMMAND ${CMAKE_COMMAND} -E copy "${CMAKE_CURRENT_SOURCE_DIR}/teleopcore_init.py" "${CMAKE_BINARY_DIR}/python_package/teleopcore/__init__.py"
18-
COMMAND ${CMAKE_COMMAND} -E copy "${CMAKE_CURRENT_SOURCE_DIR}/pyproject.toml" "${CMAKE_BINARY_DIR}/python_package/"
19-
COMMAND ${CMAKE_COMMAND} -E copy "${CMAKE_CURRENT_SOURCE_DIR}/MANIFEST.in" "${CMAKE_BINARY_DIR}/python_package/"
8+
COMMAND ${CMAKE_COMMAND} -E make_directory "${CMAKE_BINARY_DIR}/python_package/$<CONFIG>/teleopcore"
9+
COMMAND ${CMAKE_COMMAND} -E make_directory "${CMAKE_BINARY_DIR}/python_package/$<CONFIG>/teleopcore/xrio"
10+
COMMAND ${CMAKE_COMMAND} -E make_directory "${CMAKE_BINARY_DIR}/python_package/$<CONFIG>/teleopcore/oxr"
11+
COMMAND ${CMAKE_COMMAND} -E make_directory "${CMAKE_BINARY_DIR}/python_package/$<CONFIG>/teleopcore/plugin_manager"
12+
COMMAND ${CMAKE_COMMAND} -E make_directory "${CMAKE_BINARY_DIR}/python_package/$<CONFIG>/teleopcore/schema"
13+
COMMAND ${CMAKE_COMMAND} -E copy "${CMAKE_CURRENT_SOURCE_DIR}/../xrio/python/xrio_init.py" "${CMAKE_BINARY_DIR}/python_package/$<CONFIG>/teleopcore/xrio/__init__.py"
14+
COMMAND ${CMAKE_COMMAND} -E copy "${CMAKE_CURRENT_SOURCE_DIR}/../oxr/python/oxr_init.py" "${CMAKE_BINARY_DIR}/python_package/$<CONFIG>/teleopcore/oxr/__init__.py"
15+
COMMAND ${CMAKE_COMMAND} -E copy "${CMAKE_CURRENT_SOURCE_DIR}/../plugin_manager/python/plugin_manager_init.py" "${CMAKE_BINARY_DIR}/python_package/$<CONFIG>/teleopcore/plugin_manager/__init__.py"
16+
COMMAND ${CMAKE_COMMAND} -E copy "${CMAKE_CURRENT_SOURCE_DIR}/../schema/python/schema_init.py" "${CMAKE_BINARY_DIR}/python_package/$<CONFIG>/teleopcore/schema/__init__.py"
17+
COMMAND ${CMAKE_COMMAND} -E copy "${CMAKE_CURRENT_SOURCE_DIR}/teleopcore_init.py" "${CMAKE_BINARY_DIR}/python_package/$<CONFIG>/teleopcore/__init__.py"
18+
COMMAND ${CMAKE_COMMAND} -E copy "${CMAKE_CURRENT_SOURCE_DIR}/pyproject.toml" "${CMAKE_BINARY_DIR}/python_package/$<CONFIG>/"
19+
COMMAND ${CMAKE_COMMAND} -E copy "${CMAKE_CURRENT_SOURCE_DIR}/MANIFEST.in" "${CMAKE_BINARY_DIR}/python_package/$<CONFIG>/"
2020
DEPENDS xrio_py oxr_py plugin_manager_py schema_py
2121
COMMENT "Preparing Python package structure"
2222
)
2323

24+
# Generate Python type stubs (.pyi) for IDE intellisense
25+
# Uses the same Python version that built the .so files (via uv --python)
26+
# Each module is processed separately to avoid import chain issues
27+
set(STUBGEN_SCRIPT "${CMAKE_CURRENT_SOURCE_DIR}/generate_stubs.py")
28+
set(STUBGEN_PACKAGE_DIR "${CMAKE_BINARY_DIR}/python_package/$<CONFIG>")
29+
set(PY_TYPED_MARKER "${STUBGEN_PACKAGE_DIR}/teleopcore/py.typed")
30+
31+
add_custom_command(
32+
OUTPUT "${PY_TYPED_MARKER}"
33+
COMMAND ${CMAKE_COMMAND} -E echo "Generating Python type stubs..."
34+
35+
# Generate stubs for each pybind11 module
36+
COMMAND uv run --no-project --python ${TELEOPCORE_PYTHON_VERSION} --with pybind11-stubgen
37+
python "${STUBGEN_SCRIPT}" teleopcore.xrio._xrio "${STUBGEN_PACKAGE_DIR}"
38+
COMMAND uv run --no-project --python ${TELEOPCORE_PYTHON_VERSION} --with pybind11-stubgen
39+
python "${STUBGEN_SCRIPT}" teleopcore.oxr._oxr "${STUBGEN_PACKAGE_DIR}"
40+
COMMAND uv run --no-project --python ${TELEOPCORE_PYTHON_VERSION} --with pybind11-stubgen
41+
python "${STUBGEN_SCRIPT}" teleopcore.plugin_manager._plugin_manager "${STUBGEN_PACKAGE_DIR}"
42+
COMMAND uv run --no-project --python ${TELEOPCORE_PYTHON_VERSION} --with pybind11-stubgen
43+
python "${STUBGEN_SCRIPT}" teleopcore.schema._schema "${STUBGEN_PACKAGE_DIR}"
44+
45+
# Create py.typed marker file (PEP 561) - also serves as build stamp
46+
COMMAND ${CMAKE_COMMAND} -E touch "${PY_TYPED_MARKER}"
47+
DEPENDS python_package "${STUBGEN_SCRIPT}"
48+
WORKING_DIRECTORY "${CMAKE_BINARY_DIR}"
49+
COMMENT "Generating .pyi stub files for teleopcore modules"
50+
)
51+
52+
add_custom_target(python_stubs
53+
DEPENDS "${PY_TYPED_MARKER}"
54+
SOURCES "${STUBGEN_SCRIPT}"
55+
)
56+
2457
# Custom target to build the wheel using uv
2558
add_custom_target(python_wheel ALL
2659
COMMAND ${CMAKE_COMMAND} -E echo "Building Python wheel with uv..."
2760
COMMAND ${CMAKE_COMMAND} -E make_directory "${CMAKE_BINARY_DIR}/wheels"
28-
COMMAND uv build --wheel --out-dir "${CMAKE_BINARY_DIR}/wheels" "${CMAKE_BINARY_DIR}/python_package" ||
29-
python3 -m build --wheel --outdir "${CMAKE_BINARY_DIR}/wheels" "${CMAKE_BINARY_DIR}/python_package"
61+
COMMAND uv build --wheel --out-dir "${CMAKE_BINARY_DIR}/wheels" "${CMAKE_BINARY_DIR}/python_package/$<CONFIG>" ||
62+
python3 -m build --wheel --outdir "${CMAKE_BINARY_DIR}/wheels" "${CMAKE_BINARY_DIR}/python_package/$<CONFIG>"
3063
DEPENDS python_package
64+
DEPENDS python_stubs
3165
WORKING_DIRECTORY "${CMAKE_BINARY_DIR}"
3266
COMMENT "Building Python wheel with uv (fallback to python -m build)"
3367
)

src/core/python/MANIFEST.in

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,16 @@
11
include teleopcore/*.so
22
include teleopcore/*.pyd
3+
include teleopcore/*.pyi
4+
include teleopcore/py.typed
35
include teleopcore/xrio/*.so
46
include teleopcore/xrio/*.pyd
7+
include teleopcore/xrio/*.pyi
58
include teleopcore/oxr/*.so
69
include teleopcore/oxr/*.pyd
10+
include teleopcore/oxr/*.pyi
711
include teleopcore/plugin_manager/*.so
812
include teleopcore/plugin_manager/*.pyd
13+
include teleopcore/plugin_manager/*.pyi
14+
include teleopcore/schema/*.so
15+
include teleopcore/schema/*.pyd
16+
include teleopcore/schema/*.pyi

src/core/python/generate_stubs.py

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
# SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
"""Generate Python stub file (.pyi) for a single TeleopCore pybind11 module.
5+
6+
This script uses pybind11-stubgen's Python API to generate type stubs for IDE intellisense.
7+
8+
Usage:
9+
python generate_stubs.py <module_name> <package_dir>
10+
11+
Example:
12+
python generate_stubs.py teleopcore.xrio._xrio /path/to/python_package
13+
"""
14+
15+
import sys
16+
from pathlib import Path
17+
18+
19+
def generate_stub(module_name: str, package_dir: Path) -> bool:
20+
"""Generate stub file for a single pybind11 module.
21+
22+
Args:
23+
module_name: Fully qualified module name (e.g., "teleopcore.xrio._xrio")
24+
package_dir: Path to the python_package directory containing teleopcore.
25+
26+
Returns:
27+
True if successful, False otherwise.
28+
"""
29+
# Add package_dir to sys.path so pybind11-stubgen can import the module
30+
sys.path.insert(0, str(package_dir))
31+
32+
try:
33+
from pybind11_stubgen import (
34+
CLIArgs,
35+
Printer,
36+
Writer,
37+
run,
38+
stub_parser_from_args,
39+
to_output_and_subdir,
40+
)
41+
except ImportError:
42+
print("Error: pybind11-stubgen not installed")
43+
print("This script should be run via: uv run --with pybind11-stubgen")
44+
return False
45+
46+
print(f"Generating stubs for {module_name}...")
47+
48+
# Configure stubgen using the proper API
49+
args = CLIArgs(
50+
module_name=module_name,
51+
output_dir=str(package_dir),
52+
root_suffix="",
53+
ignore_invalid_expressions=None,
54+
ignore_invalid_identifiers=None,
55+
ignore_unresolved_names=None,
56+
ignore_all_errors=True, # Continue even if some signatures fail
57+
enum_class_locations=[],
58+
numpy_array_wrap_with_annotated=False,
59+
numpy_array_use_type_var=False,
60+
numpy_array_remove_parameters=False,
61+
print_invalid_expressions_as_is=False,
62+
print_safe_value_reprs=None,
63+
exit_code=True,
64+
dry_run=False,
65+
stub_extension="pyi",
66+
)
67+
68+
try:
69+
parser = stub_parser_from_args(args)
70+
printer = Printer(invalid_expr_as_ellipses=not args.print_invalid_expressions_as_is)
71+
out_dir, sub_dir = to_output_and_subdir(
72+
output_dir=args.output_dir,
73+
module_name=args.module_name,
74+
root_suffix=args.root_suffix,
75+
)
76+
writer = Writer(stub_ext=args.stub_extension)
77+
78+
run(
79+
parser=parser,
80+
printer=printer,
81+
module_name=args.module_name,
82+
out_dir=out_dir,
83+
sub_dir=sub_dir,
84+
dry_run=args.dry_run,
85+
writer=writer,
86+
)
87+
print(f" Generated stubs for {module_name}")
88+
return True
89+
90+
except Exception as e:
91+
print(f"Error: stubgen failed for {module_name}: {e}")
92+
return False
93+
94+
95+
def main() -> int:
96+
"""Main entry point."""
97+
if len(sys.argv) != 3:
98+
print(f"Usage: {sys.argv[0]} <module_name> <package_dir>")
99+
print(f"Example: {sys.argv[0]} teleopcore.xrio._xrio /path/to/python_package")
100+
return 1
101+
102+
module_name = sys.argv[1]
103+
package_dir = Path(sys.argv[2]).resolve()
104+
105+
if not package_dir.exists():
106+
print(f"Error: Package directory does not exist: {package_dir}")
107+
return 1
108+
109+
if generate_stub(module_name, package_dir):
110+
return 0
111+
else:
112+
print("Stub generation failed.")
113+
return 1
114+
115+
116+
if __name__ == "__main__":
117+
sys.exit(main())

src/core/python/pyproject.toml

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ dependencies = [
2323
dev = [
2424
"pytest>=7.0.0",
2525
"pytest-cov>=4.0.0",
26+
"pybind11-stubgen>=2.5",
2627
]
2728

2829
[tool.setuptools]
@@ -36,11 +37,11 @@ packages = [
3637
include-package-data = true
3738

3839
[tool.setuptools.package-data]
39-
teleopcore = ["*.so", "*.pyd"]
40-
"teleopcore.xrio" = ["*.so", "*.pyd"]
41-
"teleopcore.oxr" = ["*.so", "*.pyd"]
42-
"teleopcore.plugin_manager" = ["*.so", "*.pyd"]
43-
"teleopcore.schema" = ["*.so", "*.pyd"]
40+
teleopcore = ["*.so", "*.pyd", "*.pyi", "py.typed"]
41+
"teleopcore.xrio" = ["*.so", "*.pyd", "*.pyi"]
42+
"teleopcore.oxr" = ["*.so", "*.pyd", "*.pyi"]
43+
"teleopcore.plugin_manager" = ["*.so", "*.pyd", "*.pyi"]
44+
"teleopcore.schema" = ["*.so", "*.pyd", "*.pyi"]
4445

4546
[tool.uv]
4647
# Only use managed Python installations (not system Python)

src/core/schema/python/CMakeLists.txt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,5 +29,6 @@ add_dependencies(schema_py schema_header_generation)
2929

3030
set_target_properties(schema_py PROPERTIES
3131
OUTPUT_NAME "_schema"
32-
LIBRARY_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/python_package/teleopcore/schema"
32+
# Use genex in output directory - CMake won't add another config subdirectory
33+
LIBRARY_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/python_package/$<CONFIG>/teleopcore/schema"
3334
)

src/core/xrio/python/CMakeLists.txt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ target_link_libraries(xrio_py
2626

2727
set_target_properties(xrio_py PROPERTIES
2828
OUTPUT_NAME "_xrio"
29-
LIBRARY_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/python_package/teleopcore/xrio"
29+
# Use genex in output directory - CMake won't add another config subdirectory
30+
LIBRARY_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/python_package/$<CONFIG>/teleopcore/xrio"
3031
)
3132

0 commit comments

Comments
 (0)