Skip to content

Commit efec10c

Browse files
p-wysockiakuporos
andauthored
[PyOV] Initial support for type hinting using stub .pyi files (#29139)
### Turning on type hinting in VSC: - Install Python and Pylance extensions - Turn on type checking as described in https://docs.pydantic.dev/latest/integrations/visual_studio_code/#configure-pylance - The stubs should show hints for OpenVINO API: ![image](https://github.com/user-attachments/assets/f211ef90-ccf0-4bd0-a553-f31483ad7950) ### Details: - Work in progress, **draft is for internal Python team review only** - Fix or skip errors thrown during stub generation - Fix some of the errors thrown by `pylance` when reading .pyi files (there's quite a few of them, in my opinion the rest has to be fixed separately in different PRs) - Adjust CMake scripts for wheel building dependencies and for fdupes check - Add script with xfails, which will be used for stub generation - This PR is called "initial support" due to numerous leftover errors reported by `pylance`, such as: ![image](https://github.com/user-attachments/assets/7c8d0740-cfe3-45d4-b564-ed93489a8a0d) In my opinion the rest should be fixed systematically and in scope of other PRs, mostly due to variety of errors, which often require case by case analysis. ### TBD: - ~~The workflow for updating stubs has to be decided on. In current implementation, the stubs need to be generated manually and committed. They are also stored in the repository. Maybe they should be generated just before wheel building (automatically) and not uploaded to GitHub?~~ **The current plan is to automatically generate and commit `.pyi` files using pre-commit git hook.** - ~~If we decide to keep `.pyi` files like GenAI does, we'll need a CI check to assert that they're up to date, similar to GenAI: openvinotoolkit/openvino.genai#1214 - **will be continued in an epic specific to PyAPI stubs introduction** - ~~Should the `generate_pyapi_stubs.sh` script be in `openvino/scripts`?~~ **Moved to Python directory** - ~~Is the current implementation sufficient for initial support or should we first go through the modules and fix all the `pylance` errors?~~ ### To do: - Verify the `.bat` script is working correctly ### Tickets: - CVS-157373 --------- Signed-off-by: p-wysocki <[email protected]> Co-authored-by: Anastasia Kuporosova <[email protected]>
1 parent f2d548d commit efec10c

File tree

134 files changed

+18327
-136
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

134 files changed

+18327
-136
lines changed

.gitignore

+3-1
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,11 @@ _*
33
[Bb]uild*/
44
cmake-build*
55

6-
# but ensure we don't skip __init__.py and __main__.py
6+
# but ensure we don't skip Python files
77
!__init__.py
88
!__main__.py
9+
!*.pyi
10+
911
# and sphinx documentation folders
1012
!docs/sphinx_setup/_*
1113

src/bindings/python/CMakeLists.txt

+35
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,8 @@ macro(ov_define_setup_py_dependencies)
271271
endforeach()
272272

273273
file(GLOB_RECURSE openvino_py_files ${OpenVINOPython_SOURCE_DIR}/src/openvino/*.py)
274+
file(GLOB_RECURSE openvino_pyi_files ${OpenVINOPython_SOURCE_DIR}/src/openvino/*.pyi)
275+
list(APPEND openvino_py_files ${openvino_pyi_files})
274276

275277
list(APPEND ov_setup_py_deps
276278
${openvino_py_files}
@@ -411,3 +413,36 @@ if(OpenVINODeveloperPackage_FOUND)
411413

412414
ov_cpack(${OV_CPACK_COMPONENTS_ALL})
413415
endif()
416+
417+
# only for developers, skip on CI
418+
if(NOT DEFINED ENV{CI_BUILD_DEV_TAG} AND NOT DEFINED CI_BUILD_NUMBER)
419+
420+
# check pybind11-stubgen package
421+
execute_process(COMMAND ${Python3_EXECUTABLE} -m pip show pybind11-stubgen
422+
OUTPUT_VARIABLE pybind11_stubgen_info OUTPUT_STRIP_TRAILING_WHITESPACE
423+
RESULT_VARIABLE pybind11_stubgen_result)
424+
string(REGEX MATCH "Version: ([\\.0-9]+)" pybind11_stubgen_version "${pybind11_stubgen_info}")
425+
if(pybind11_stubgen_result OR NOT pybind11_stubgen_version)
426+
message(FATAL_ERROR "pybind11-stubgen package is not installed or version is not detected. This package is necessary for developing Python API.")
427+
endif()
428+
429+
# check pre-commit package
430+
execute_process(COMMAND ${Python3_EXECUTABLE} -m pip show pre-commit
431+
OUTPUT_VARIABLE precommit_pkg_info OUTPUT_STRIP_TRAILING_WHITESPACE
432+
RESULT_VARIABLE precommit_pkg_result)
433+
string(REGEX MATCH "Version: ([\\.0-9]+)" precommit_pkg_version "${precommit_pkg_info}")
434+
if(precommit_pkg_result OR NOT precommit_pkg_version)
435+
message(FATAL_ERROR "pre-commit package is not installed or version is not detected. This package is necessary for developing Python API.")
436+
endif()
437+
438+
# attach the pre-commit hook for Python stub generation
439+
execute_process(COMMAND pre-commit install -c "${CMAKE_CURRENT_SOURCE_DIR}/scripts/stub_generation_precommit.yaml"
440+
RESULT_VARIABLE precommit_install_result
441+
ERROR_VARIABLE precommit_install_error
442+
OUTPUT_STRIP_TRAILING_WHITESPACE
443+
ERROR_STRIP_TRAILING_WHITESPACE)
444+
if(NOT precommit_install_result EQUAL "0")
445+
message(FATAL_ERROR "pre-commit hook install failed with error: ${precommit_install_error}")
446+
endif()
447+
message(STATUS "pre-commit hook for Python API stub generation has been installed")
448+
endif()

src/bindings/python/requirements_test.txt

+2
Original file line numberDiff line numberDiff line change
@@ -36,3 +36,5 @@ retrying
3636
tox
3737
types-setuptools
3838
wheel
39+
pybind11-stubgen<2.6
40+
pre-commit<4.3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
@echo off
2+
3+
REM Copyright (C) 2018-2025 Intel Corporation
4+
REM SPDX-License-Identifier: Apache-2.0
5+
6+
setlocal EnableDelayedExpansion
7+
8+
REM Invalid expressions
9+
set "invalid_expressions=ov::op::v1::Add ov::op::v1::Divide ov::op::v1::Multiply ov::op::v1::Subtract ov::op::v1::Divide ov::Node ov::Input<ov::Node> ov::descriptor::Tensor <Type: 'undefined'> ov::Output<ov::Node const> ov::float16 ov::EncryptionCallbacks ov::streams::Num <Dimension: dynamic ov::pass::pattern::PatternSymbolValue <RTMap>"
10+
11+
REM Invalid identifiers
12+
set "invalid_identifiers=<locals>"
13+
14+
REM Unresolved names
15+
set "unresolved_names=InferRequestWrapper RemoteTensorWrapper capsule VASurfaceTensorWrapper _abc._abc_data openvino._ov_api.undefined_deprecated InputCutInfo ParamData"
16+
17+
REM Function to escape characters for regex
18+
:escape_characters
19+
set "escaped_error=%~1"
20+
set "escaped_error=!escaped_error:\=\\!"
21+
set "escaped_error=!escaped_error:/=\/!"
22+
set "escaped_error=!escaped_error:$=\$!"
23+
set "escaped_error=!escaped_error:*=\*!"
24+
set "escaped_error=!escaped_error:.=\.!"
25+
set "escaped_error=!escaped_error:^=\^!"
26+
set "escaped_error=!escaped_error:|=\|!"
27+
exit /b 0
28+
29+
REM Create regex pattern
30+
set "invalid_expressions_regex="
31+
for %%e in (%invalid_expressions%) do (
32+
call :escape_characters "%%e"
33+
set "invalid_expressions_regex=!invalid_expressions_regex!.*!escaped_error!.*|"
34+
)
35+
set "invalid_expressions_regex=%invalid_expressions_regex:~0,-1%"
36+
37+
set "invalid_identifiers_regex="
38+
for %%e in (%invalid_identifiers%) do (
39+
call :escape_characters "%%e"
40+
set "invalid_identifiers_regex=!invalid_identifiers_regex!.*!escaped_error!.*|"
41+
)
42+
set "invalid_identifiers_regex=%invalid_identifiers_regex:~0,-1%"
43+
44+
set "unresolved_names_regex="
45+
for %%e in (%unresolved_names%) do (
46+
call :escape_characters "%%e"
47+
set "unresolved_names_regex=!unresolved_names_regex!.*!escaped_error!.*|"
48+
)
49+
set "unresolved_names_regex=%unresolved_names_regex:~0,-1%"
50+
51+
REM Set the output directory
52+
if "%~1"=="" (
53+
set "output_dir=%~dp0.."
54+
) else (
55+
set "output_dir=%~1"
56+
)
57+
58+
REM Generate stubs for C++ bindings
59+
python -m pybind11_stubgen --output-dir "%output_dir%" --root-suffix "" --ignore-invalid-expressions "%invalid_expressions_regex%" --ignore-invalid-identifiers "%invalid_identifiers_regex%" --ignore-unresolved-names "%unresolved_names_regex%" --print-invalid-expressions-as-is --numpy-array-use-type-var --exit-code openvino
60+
61+
REM Check if the command was successful
62+
if %errorlevel% neq 0 (
63+
echo Error: pybind11-stubgen failed.
64+
exit /b 1
65+
)
66+
67+
REM Check if the stubs were actually generated
68+
if exist "%output_dir%\openvino" (
69+
echo Stub files generated successfully.
70+
) else (
71+
echo No stub files were generated.
72+
exit /b 1
73+
)
74+
75+
REM Workaround for pybind11-stubgen issue where it doesn't import some modules for stubs generated from .py files
76+
REM Ticket: 163225
77+
set "pyi_file=%output_dir%\openvino\_ov_api.pyi"
78+
if exist "%pyi_file%" (
79+
powershell -Command "(gc '%pyi_file%') -replace '(^.*$)', 'import typing`r`nimport pathlib`r`n$1' | Out-File -encoding ASCII '%pyi_file%'"
80+
) else (
81+
echo File %pyi_file% not found.
82+
exit /b 1
83+
)
84+
85+
REM Find all changed .pyi files
86+
for /f "tokens=*" %%f in ('git diff --name-only ^| findstr /r "\.pyi$"') do (
87+
REM Process each changed .pyi file
88+
powershell -Command "(gc '%%f') -replace '<function _get_node_factory at 0x[0-9a-fA-F]+>', '<function _get_node_factory at memory_address>' | Out-File -encoding ASCII '%%f'"
89+
powershell -Command "(gc '%%f') -replace '__version__: str = ''[^'']*''', '__version__: str = ''version_string''' | Out-File -encoding ASCII '%%f'"
90+
powershell -Command "(gc '%%f') -replace '<function <lambda> at 0x[0-9a-fA-F]+>', '<function <lambda> at memory_address>' | Out-File -encoding ASCII '%%f'"
91+
powershell -Command "(gc '%%f') -replace ': \.\.\.', ': typing.Any' | Out-File -encoding ASCII '%%f'"
92+
powershell -Command "(gc '%%f') -replace 'pass: MatcherPass', 'matcher_pass: MatcherPass' | Out-File -encoding ASCII '%%f'"
93+
REM Sort consecutive import statements at the beginning of the file
94+
powershell -Command @"
95+
\$content = Get-Content '%%f'
96+
\$in_imports = \$false
97+
\$start = 0
98+
\$imports = @()
99+
100+
foreach (\$line in \$content) {
101+
if (\$line -match '^from ' -or \$line -match '^import ') {
102+
if (-not \$in_imports) {
103+
\$start = \$true
104+
}
105+
\$in_imports = \$true
106+
\$imports += \$line
107+
} else {
108+
if (\$in_imports) {
109+
\$imports = \$imports | Sort-Object
110+
\$imports | Out-File -Append -Encoding ASCII '%%f'
111+
\$in_imports = \$false
112+
\$imports = @()
113+
}
114+
Add-Content -Path '%%f' -Value \$line
115+
}
116+
}
117+
118+
if (\$in_imports) {
119+
\$imports = \$imports | Sort-Object
120+
\$imports | Out-File -Append -Encoding ASCII '%%f'
121+
}
122+
Add-Content -Path '%%f' -Value '# type: ignore'
123+
"@
124+
)
125+
126+
endlocal
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
#!/bin/bash
2+
3+
# Copyright (C) 2018-2025 Intel Corporation
4+
# SPDX-License-Identifier: Apache-2.0
5+
6+
# When removing xfails remember to remove them from .bat script as well
7+
invalid_expressions=(
8+
# The classes bindings' will be soon added to pyopenvino. Ticket: 163077
9+
"ov::op::v1::Add"
10+
"ov::op::v1::Divide"
11+
"ov::op::v1::Multiply"
12+
"ov::op::v1::Subtract"
13+
"ov::op::v1::Divide"
14+
15+
# Circular dependency Input/Output/Node/Tensor. Ticket: 163078
16+
"ov::Node"
17+
"ov::Input<ov::Node>"
18+
"ov::descriptor::Tensor"
19+
"<Type: 'undefined'>"
20+
"ov::Output<ov::Node const>"
21+
22+
# New bindings required, ticket: 163094
23+
"ov::float16"
24+
"ov::EncryptionCallbacks"
25+
"ov::streams::Num"
26+
"ov::pass::pattern::PatternSymbolValue"
27+
28+
# Other issues, ticket: 163094
29+
"<Dimension:"
30+
"dynamic"
31+
"<RTMap>"
32+
)
33+
34+
invalid_identifiers=(
35+
# Ticket: 163093
36+
"<locals>"
37+
)
38+
39+
unresolved_names=(
40+
# Circular dependencies, ticket: 163078
41+
"InferRequestWrapper"
42+
43+
# Other issues, ticket: 163094
44+
"RemoteTensorWrapper"
45+
"capsule"
46+
"VASurfaceTensorWrapper"
47+
"_abc._abc_data"
48+
"openvino._ov_api.undefined_deprecated"
49+
"InputCutInfo"
50+
"ParamData"
51+
)
52+
53+
create_regex_pattern() {
54+
local errors=("$@")
55+
local regex_pattern=""
56+
57+
for error in "${errors[@]}"; do
58+
escaped_error=$(printf '%s\n' "$error" | sed -e 's/[]\/$*.^|[]/\\&/g')
59+
regex_pattern+=".*$escaped_error.*|"
60+
done
61+
regex_pattern="${regex_pattern%|}"
62+
echo "$regex_pattern"
63+
}
64+
65+
invalid_expressions_regex=$(create_regex_pattern "${invalid_expressions[@]}")
66+
invalid_identifiers_regex=$(create_regex_pattern "${invalid_identifiers[@]}")
67+
unresolved_names_regex=$(create_regex_pattern "${unresolved_names[@]}")
68+
69+
# Set the output directory
70+
if [ -z "$1" ]; then
71+
output_dir="$(dirname "$0")/../src"
72+
else
73+
output_dir="$1"
74+
fi
75+
76+
# Generate stubs for C++ bindings
77+
if ! python -m pybind11_stubgen \
78+
--output-dir "$output_dir" \
79+
--root-suffix "" \
80+
--ignore-invalid-expressions "$invalid_expressions_regex" \
81+
--ignore-invalid-identifiers "$invalid_identifiers_regex" \
82+
--ignore-unresolved-names "$unresolved_names_regex" \
83+
--numpy-array-use-type-var \
84+
--exit-code \
85+
openvino; then
86+
echo "Error: pybind11-stubgen failed."
87+
exit 1
88+
fi
89+
90+
# Workaround for pybind11-stubgen issue where it doesn't import some modules for stubs generated from .py files
91+
# Ticket: 163225
92+
pyi_file="$output_dir/openvino/_ov_api.pyi"
93+
if [ -f "$pyi_file" ]; then
94+
sed -i '2i import typing' "$pyi_file"
95+
sed -i '2i import pathlib' "$pyi_file"
96+
else
97+
echo "File $pyi_file not found."
98+
exit 1
99+
fi
100+
101+
# Find all changed .pyi files
102+
changed_files=$(git diff --name-only | grep '\.pyi$')
103+
# Process each changed .pyi file
104+
for file in $changed_files; do
105+
sed -i 's/<function _get_node_factory at 0x[0-9a-fA-F]\+>/<function _get_node_factory at memory_address>/' "$file"
106+
sed -i "s/__version__: str = '[^']*'/__version__: str = 'version_string'/" "$file"
107+
sed -i 's/<function <lambda> at 0x[0-9a-fA-F]\{1,\}>/<function <lambda> at memory_address>/g' "$file"
108+
sed -i 's/: \.\.\./: typing.Any/g' "$file"
109+
sed -i 's/pass: MatcherPass/matcher_pass: MatcherPass/g' "$file"
110+
# Sort consecutive import statements
111+
awk '
112+
BEGIN { in_imports = 0; }
113+
/^from / || /^import / {
114+
if (in_imports == 0) {
115+
start = NR;
116+
}
117+
in_imports++;
118+
imports[in_imports] = $0;
119+
next;
120+
}
121+
{
122+
if (in_imports > 0) {
123+
for (i = 1; i <= in_imports; i++) {
124+
print imports[i] | "sort";
125+
}
126+
close("sort");
127+
in_imports = 0;
128+
}
129+
print;
130+
}
131+
END {
132+
if (in_imports > 0) {
133+
for (i = 1; i <= in_imports; i++) {
134+
print imports[i] | "sort";
135+
}
136+
close("sort");
137+
}
138+
}
139+
' "$file" > "$file.sorted"
140+
mv "$file.sorted" "$file"
141+
sed -i '1i # type: ignore' "$file"
142+
done
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
repos:
2+
- repo: local
3+
hooks:
4+
- id: generate_stubs
5+
name: Generate .pyi files
6+
language: system
7+
entry: >
8+
bash -c 'if [[ "$OSTYPE" == "msys" ]]; then
9+
"$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/src/bindings/python/scripts/generate_pyapi_stubs.bat";
10+
else
11+
"$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/src/bindings/python/scripts/generate_pyapi_stubs.sh";
12+
fi;
13+
if [ $? -ne 0 ]; then
14+
echo "Stub generation failed. Aborting commit.";
15+
exit 1;
16+
fi;
17+
18+
git add ./*.pyi'
19+
files: .*src/bindings/python/.*
20+
pass_filenames: false
21+
verbose: true
22+
stages: [pre-commit]

0 commit comments

Comments
 (0)