This project demonstrates how to manually add pybind11 as a Git submodule to expose a simple C++ class and function to Python.
While building the project was straightforward, configuring the IDE was challenging. There are two parts covered in this document:
- Part 1: Set up and run a Python script to test the imported C++ module.
- Part 2: Troubleshoot IntelliSense and Pylance dependency issues.
Take a look at CMakeLists.txt:
add_subdirectory(extern/pybind11)
This line tells CMake where to find the pybind11 dependency.
- Create and activate a virtual environment
python3 -m venv .venv
source .venv/bin/activate- Initialize the repository and add
pybind11
Add pybind11 as a Git submodule under extern/
git init
git submodule add -b stable https://github.com/pybind/pybind11 extern/pybind11
git submodule update --init --recursive- Configure and Build
cmake -S . -B build
cmake --build buildAfter building the project, a .so file should be generated under the build directory.
The test script is located under script/. Run the script with command:
python3 ./script/test*.pyPart 2 walks through the issues in the order they came up while building the project, explaining why each one occurred and how to solve it.
Even if the project builds successfully with CMake, VS Code may still show red squiggles in the C++ source files. That's because the compiler and IntelliSense are separate tools — IntelliSense needs its own copy of the compiler settings, and doesn't get them automatically. The fix is to have CMake generate that information and feed it to IntelliSense.
Add the following to CMakeLists.txt:
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)Then configure the project:
cmake -S . -B buildCMake will generate:
./build/compile_commands.json
The compilation database contains information such as:
-I <include directory>
-D <preprocessor definition>
-std=<C++ standard>
compiler flags
...
This gives IntelliSense the exact compile command for each file, instead of manually duplicating the config.
On VS Code (macOS example), create .vscode/c_cpp_properties.json pointing the C/C++ extension at the compilation database:
{
"configurations": [
{
"name": "Mac",
"compileCommands": "${workspaceFolder}/build/compile_commands.json"
}
],
"version": 4
}This keeps CMake as the single source of truth, so there's no need to manually duplicate dependency paths like:
"includePath": [
"${workspaceFolder}/extern/pybind11",
"${workspaceFolder}/any/include/path"
]If red squiggles remain after configuring compile_commands.json, try the following.
rm -rf build
cmake -S . -B buildCmd + Shift + P
→ Developer: Reload Window
Similarly on the Python side: the compiled module (.so) lives in build/, so the interpreter needs to be told where to find it:
import sys
import os
# Add the build directory to Python's module search path.
sys.path.append(os.path.abspath("./build"))
import cpp_modules as cppThis works at runtime, but Pylance may still flag import cpp_modules as cpp with a red squiggle — it performs static analysis and doesn't automatically know where the module is located.
Create .vscode/settings.json:
{
"python.analysis.extraPaths": [
"${workspaceFolder}/build"
]
}This solves module location, but not content — typing cpp. still won't autocomplete, since Pylance can find the .so file but doesn't know its interface.
pybind11-stubgen inspects the compiled module and generates a .pyi stub describing its interface. With the virtual environment active, install it:
pip install pybind11-stubgenRun:
PYTHONPATH=./build pybind11-stubgen cpp_modules --output-dir ./buildThis generates cpp_modules.pyi in build/: PYTHONPATH=./build lets pybind11-stubgen import the compiled module, and --output-dir ./build places the stub next to it.
settings.json tells Pylance where to look; the .pyi stub tells it what's inside. Miss extraPaths and Pylance won't find the module or stub at all; miss the .pyi and it'll find the module but not its classes, functions, or signatures.
The .pyi file is a snapshot, not live introspection — whenever a binding changes (e.g. a .def(...) edit), rebuild and regenerate it, or the stub goes stale:
cmake --build build
PYTHONPATH=./build pybind11-stubgen cpp_modules --output-dir ./buildThe project uses three different tools for three different purposes:
| Tool | Purpose |
|---|---|
| CMake | Configure and build the C++ project |
| IntelliSense | Analyze C++ code and provide completion |
| Pylance | Analyze Python code and provide completion |
For C++:
CMakeLists.txt
│
▼
CMake
│
├──► build system ──► compiler ──► actual program
│
└──► compile_commands.json
│
▼
IntelliSense
For Python:
Pylance
│
┌─────────┴─────────┐
│ │
extraPaths .pyi stub
│ │
▼ ▼
Where is the What does the
module located? module contain?
│ │
└─────────┬─────────┘
▼
Code completion
The main idea is to avoid manually duplicating configuration wherever possible:
Let CMake define how the project is built, and let the language servers consume the information they need from the build artifacts.