diff --git a/.github/actions/generic-ci/action.yml b/.github/actions/generic-ci/action.yml index 2423088e23..9aa3483977 100644 --- a/.github/actions/generic-ci/action.yml +++ b/.github/actions/generic-ci/action.yml @@ -102,6 +102,7 @@ runs: run: | python -m pip install --upgrade pip python -m pip install pytest==8.0.0 + python -m pip install pybind11_stubgen - name: Setup Directories shell: bash @@ -151,6 +152,7 @@ runs: -DCMAKE_PREFIX_PATH:PATH=$(pwd)/../dependencies/install/ -DF3D_BINDINGS_JAVA=${{ (runner.os != 'macOS' || inputs.cpu == 'arm64') && inputs.optional_deps_label == 'optional-deps' && 'ON' || 'OFF' }} -DF3D_BINDINGS_PYTHON=${{ inputs.optional_deps_label == 'optional-deps' && 'ON' || 'OFF' }} + -DF3D_BINDINGS_PYTHON_GENERATE_STUBS=${{ inputs.optional_deps_label == 'optional-deps' && 'ON' || 'OFF' }} -DF3D_EXCLUDE_DEPRECATED=${{ inputs.exclude_deprecated_label == 'exclude-deprecated' && 'ON' || 'OFF' }} -DF3D_LINUX_GENERATE_MAN=ON -DF3D_LINUX_INSTALL_DEFAULT_CONFIGURATION_FILE_IN_PREFIX=ON @@ -198,6 +200,12 @@ runs: with: path: ${{github.workspace}}\build\bin_Release + - name: Install Mesa Windows Python + if: runner.os == 'Windows' + uses: f3d-app/install-mesa-windows-action@v1 + with: + path: ${{github.workspace}}\build\Release\f3d + # A EGL test is crashing in the CI but not locally - name: Set CI test exception for Linux EGL if: diff --git a/doc/dev/BUILD.md b/doc/dev/BUILD.md index cd008ab3f2..137391c096 100644 --- a/doc/dev/BUILD.md +++ b/doc/dev/BUILD.md @@ -51,6 +51,7 @@ Some modules, plugins and language bindings depending on external libraries can * `F3D_PLUGIN_BUILD_USD`: Support for USD file format. Requires `OpenUSD`. Disabled by default. * `F3D_PLUGIN_BUILD_VDB`: Support for VDB file format. Requires that VTK has been built with `IOOpenVDB` module (and `OpenVDB`). Disabled by default. * `F3D_BINDINGS_PYTHON`: Generate python bindings (requires `Python` and `pybind11`). Disabled by default. +* `F3D_BINDINGS_PYTHON_GENERATE_STUBS`: Generate python stubs (requires `Python` and `pybind11_stubgen`). Disabled by default. * `F3D_BINDINGS_JAVA`: Generate java bindings (requires `Java` and `JNI`). Disabled by default. Some dependencies are provided internally, eg: ImGui, dmon and others. Use `F3D_USE_EXTERNAL_*` to use an external version of these libraries. diff --git a/doc/libf3d/LANGUAGE_BINDINGS.md b/doc/libf3d/LANGUAGE_BINDINGS.md index d88b99b086..58678a241d 100644 --- a/doc/libf3d/LANGUAGE_BINDINGS.md +++ b/doc/libf3d/LANGUAGE_BINDINGS.md @@ -23,6 +23,11 @@ eng.interactor.start() You can see more examples using python bindings in the dedicated example folder [here](https://github.com/f3d-app/f3d/tree/master/examples/libf3d/python). +### Stubs + +It's also possible to generate Python stubs automatically by enabling the CMake option `F3D_BINDINGS_PYTHON_GENERATE_STUBS`. +Python stubs are `.pyi` files defining the public interface, allowing IDEs to auto-complete and do static analysis. + ## Java (experimental) If the Java bindings have been generated using the `F3D_BINDINGS_JAVA` CMake option, the libf3d can be used directly from Java. diff --git a/pyproject.toml b/pyproject.toml index d1e2a78b60..862c6e17e2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,10 +1,10 @@ [build-system] -requires = ["scikit-build-core", "setuptools-scm>=8.0"] +requires = ["scikit-build-core", "setuptools-scm>=8.0", "pybind11_stubgen"] build-backend = "scikit_build_core.build" [project] name = "f3d" -requires-python = ">=3.8" +requires-python = ">=3.9" dynamic = ["version"] description = "F3D, a fast and minimalist 3D viewer" readme = "README.md" @@ -58,6 +58,7 @@ fallback_version = "2.5.1" CMAKE_OSX_DEPLOYMENT_TARGET = "10.15" BUILD_SHARED_LIBS = "ON" F3D_BINDINGS_PYTHON = "ON" +F3D_BINDINGS_PYTHON_GENERATE_STUBS = "ON" F3D_PLUGINS_STATIC_BUILD = "ON" F3D_BUILD_APPLICATION = "OFF" F3D_WINDOWS_BUILD_SHELL_THUMBNAILS_EXTENSION = "OFF" diff --git a/python/CMakeLists.txt b/python/CMakeLists.txt index 96e31a07c7..45b43eb094 100644 --- a/python/CMakeLists.txt +++ b/python/CMakeLists.txt @@ -2,11 +2,13 @@ cmake_minimum_required(VERSION 3.21) project(pyf3d) +option(F3D_BINDINGS_PYTHON_GENERATE_STUBS "Generate Python stubs" OFF) + list(INSERT CMAKE_MODULE_PATH 0 "${CMAKE_CURRENT_LIST_DIR}/cmake") include(GNUInstallDirs) include(f3dPython) -find_package(Python 3.8 COMPONENTS Interpreter Development) +find_package(Python 3.9 COMPONENTS Interpreter Development) find_package(pybind11 2.2 REQUIRED) pybind11_add_module(pyf3d MODULE F3DPythonBindings.cxx) @@ -25,7 +27,8 @@ set(f3d_python_package_name "f3d") set(f3d_python_package_suffix "python${Python_VERSION_MAJOR}.${Python_VERSION_MINOR}/site-packages") get_property(F3D_MULTI_CONFIG_GENERATOR GLOBAL PROPERTY GENERATOR_IS_MULTI_CONFIG) -set(f3d_module_dir "${CMAKE_BINARY_DIR}$<${F3D_MULTI_CONFIG_GENERATOR}:/$>/${f3d_python_package_name}") +set(f3d_binary_dir "${CMAKE_BINARY_DIR}$<${F3D_MULTI_CONFIG_GENERATOR}:/$>") +set(f3d_module_dir "${f3d_binary_dir}/${f3d_python_package_name}") set_target_properties(pyf3d PROPERTIES CXX_STANDARD 17 @@ -56,7 +59,11 @@ if(WIN32) if(PROJECT_IS_TOP_LEVEL) f3d_python_windows_dll_fixup(PATHS "$" OUTPUT F3D_ABSOLUTE_DLLS_FIXUP) else() - f3d_python_windows_dll_fixup(PATHS "${CMAKE_RUNTIME_OUTPUT_DIRECTORY}" "$" OUTPUT F3D_ABSOLUTE_DLLS_FIXUP) + set(f3d_win_dll_paths "$") + if(BUILD_SHARED_LIBS) + list(APPEND f3d_win_dll_paths "$") + endif() + f3d_python_windows_dll_fixup(PATHS "${f3d_win_dll_paths}" OUTPUT F3D_ABSOLUTE_DLLS_FIXUP) endif() endif() @@ -83,6 +90,15 @@ endif() configure_file("${CMAKE_CURRENT_SOURCE_DIR}/__init__.py.in" "${CMAKE_CURRENT_BINARY_DIR}/__init__.py-install" @ONLY) +# Stubs +if (F3D_BINDINGS_PYTHON_GENERATE_STUBS) + add_custom_command( + TARGET pyf3d + POST_BUILD + COMMAND python ${CMAKE_CURRENT_SOURCE_DIR}/generate_stubs.py --into=${f3d_binary_dir} + WORKING_DIRECTORY ${f3d_binary_dir}) +endif() + # testing if(BUILD_TESTING) add_subdirectory(testing) @@ -93,3 +109,7 @@ install(TARGETS pyf3d LIBRARY DESTINATION ${f3d_python_install_path} COMPONENT python) install(FILES "${CMAKE_CURRENT_BINARY_DIR}/__init__.py-install" RENAME "__init__.py" DESTINATION ${f3d_python_install_path} COMPONENT python) +if (F3D_BINDINGS_PYTHON_GENERATE_STUBS) + install(FILES "${f3d_module_dir}/__init__.pyi" "${f3d_module_dir}/pyf3d.pyi" + DESTINATION ${f3d_python_install_path} COMPONENT python) +endif() diff --git a/python/F3DPythonBindings.cxx b/python/F3DPythonBindings.cxx index 96c54a7f98..3ff0fe1eb8 100644 --- a/python/F3DPythonBindings.cxx +++ b/python/F3DPythonBindings.cxx @@ -294,6 +294,16 @@ PYBIND11_MODULE(pyf3d, module) .def("load_animation_time", &f3d::scene::loadAnimationTime) .def("animation_time_range", &f3d::scene::animationTimeRange); + // f3d::camera_state_t + py::class_(module, "CameraState") + .def(py::init<>()) + .def(py::init()) + .def_readwrite("position", &f3d::camera_state_t::position) + .def_readwrite("focal_point", &f3d::camera_state_t::focalPoint) + .def_readwrite("view_up", &f3d::camera_state_t::viewUp) + .def_readwrite("view_angle", &f3d::camera_state_t::viewAngle); + // f3d::camera py::class_> camera(module, "Camera"); camera // @@ -320,15 +330,6 @@ PYBIND11_MODULE(pyf3d, module) .def("reset_to_default", &f3d::camera::resetToDefault) .def("reset_to_bounds", &f3d::camera::resetToBounds, py::arg("zoom_factor") = 0.9); - py::class_(module, "CameraState") - .def(py::init<>()) - .def(py::init()) - .def_readwrite("position", &f3d::camera_state_t::position) - .def_readwrite("focal_point", &f3d::camera_state_t::focalPoint) - .def_readwrite("view_up", &f3d::camera_state_t::viewUp) - .def_readwrite("view_angle", &f3d::camera_state_t::viewAngle); - // f3d::window py::class_> window(module, "Window"); @@ -367,6 +368,28 @@ PYBIND11_MODULE(pyf3d, module) .def("get_display_from_world", &f3d::window::getDisplayFromWorld, "Get display coordinate point from world coordinate"); + // libInformation + py::class_(module, "LibInformation") + .def_readonly("version", &f3d::engine::libInformation::Version) + .def_readonly("version_full", &f3d::engine::libInformation::VersionFull) + .def_readonly("build_date", &f3d::engine::libInformation::BuildDate) + .def_readonly("build_system", &f3d::engine::libInformation::BuildSystem) + .def_readonly("compiler", &f3d::engine::libInformation::Compiler) + .def_readonly("modules", &f3d::engine::libInformation::Modules) + .def_readonly("vtk_version", &f3d::engine::libInformation::VTKVersion) + .def_readonly("copyrights", &f3d::engine::libInformation::Copyrights) + .def_readonly("license", &f3d::engine::libInformation::License); + + // readerInformation + py::class_(module, "ReaderInformation") + .def_readonly("name", &f3d::engine::readerInformation::Name) + .def_readonly("description", &f3d::engine::readerInformation::Description) + .def_readonly("extensions", &f3d::engine::readerInformation::Extensions) + .def_readonly("mime_types", &f3d::engine::readerInformation::MimeTypes) + .def_readonly("plugin_name", &f3d::engine::readerInformation::PluginName) + .def_readonly("has_scene_reader", &f3d::engine::readerInformation::HasSceneReader) + .def_readonly("has_geometry_reader", &f3d::engine::readerInformation::HasGeometryReader); + // f3d::engine py::class_ engine(module, "Engine"); @@ -407,39 +430,9 @@ PYBIND11_MODULE(pyf3d, module) .def_static("get_readers_info", &f3d::engine::getReadersInfo) .def_static("get_rendering_backend_list", &f3d::engine::getRenderingBackendList); - // libInformation - py::class_(module, "LibInformation") - .def_readonly("version", &f3d::engine::libInformation::Version) - .def_readonly("version_full", &f3d::engine::libInformation::VersionFull) - .def_readonly("build_date", &f3d::engine::libInformation::BuildDate) - .def_readonly("build_system", &f3d::engine::libInformation::BuildSystem) - .def_readonly("compiler", &f3d::engine::libInformation::Compiler) - .def_readonly("modules", &f3d::engine::libInformation::Modules) - .def_readonly("vtk_version", &f3d::engine::libInformation::VTKVersion) - .def_readonly("copyrights", &f3d::engine::libInformation::Copyrights) - .def_readonly("license", &f3d::engine::libInformation::License); - - // readerInformation - py::class_(module, "ReaderInformation") - .def_readonly("name", &f3d::engine::readerInformation::Name) - .def_readonly("description", &f3d::engine::readerInformation::Description) - .def_readonly("extensions", &f3d::engine::readerInformation::Extensions) - .def_readonly("mime_types", &f3d::engine::readerInformation::MimeTypes) - .def_readonly("plugin_name", &f3d::engine::readerInformation::PluginName) - .def_readonly("has_scene_reader", &f3d::engine::readerInformation::HasSceneReader) - .def_readonly("has_geometry_reader", &f3d::engine::readerInformation::HasGeometryReader); - // f3d::log py::class_ log(module, "Log"); - log // - .def_static("set_verbose_level", &f3d::log::setVerboseLevel, py::arg("level"), - py::arg("force_std_err") = false) - .def_static("set_use_coloring", &f3d::log::setUseColoring) - .def_static("print", - [](f3d::log::VerboseLevel& level, const std::string& message) - { f3d::log::print(level, message); }); - py::enum_(log, "VerboseLevel") .value("DEBUG", f3d::log::VerboseLevel::DEBUG) .value("INFO", f3d::log::VerboseLevel::INFO) @@ -447,4 +440,12 @@ PYBIND11_MODULE(pyf3d, module) .value("ERROR", f3d::log::VerboseLevel::ERROR) .value("QUIET", f3d::log::VerboseLevel::QUIET) .export_values(); + + log // + .def_static("set_verbose_level", &f3d::log::setVerboseLevel, py::arg("level"), + py::arg("force_std_err") = false) + .def_static("set_use_coloring", &f3d::log::setUseColoring) + .def_static("print", + [](f3d::log::VerboseLevel& level, const std::string& message) + { f3d::log::print(level, message); }); } diff --git a/python/__init__.py.in b/python/__init__.py.in index 1cbcd1ac0f..b7d9e3282e 100644 --- a/python/__init__.py.in +++ b/python/__init__.py.in @@ -2,10 +2,11 @@ # Refer to python/__init__.py.in source file import os -import sys import re +import sys import warnings from pathlib import Path +from typing import Any, Iterable, Mapping, Union F3D_ABSOLUTE_DLLS = [ # @F3D_ABSOLUTE_DLLS_FIXUP@ @@ -32,7 +33,9 @@ __version__ = "@F3D_VERSION@" # monkey patch `options.update` -def f3d_options_update(self, arg): +def _f3d_options_update( + self, arg: Union[Mapping[str, Any], Iterable[tuple[str, Any]]] +) -> None: try: for k, v in arg.items(): self[k] = v @@ -47,17 +50,17 @@ def f3d_options_update(self, arg): except AttributeError: pass - raise ValueError(f"cannot update {self} from {args}") + raise ValueError(f"cannot update {self} from {arg}") -Options.update = f3d_options_update +Options.update = _f3d_options_update ################################################################################ # add deprecated warnings -def deprecated_decorator(f, reason): +def _deprecated_decorator(f, reason): def g(*args, **kwargs): warnings.warn(reason, DeprecationWarning, 2) return f(*args, **kwargs) @@ -65,7 +68,7 @@ def deprecated_decorator(f, reason): return g -def add_deprecation_warnings(): +def _add_deprecation_warnings(): for f3d_class in ( Camera, Scene, @@ -73,6 +76,7 @@ def add_deprecation_warnings(): Interactor, Engine, Window, + Image, ): for name, member in f3d_class.__dict__.items(): if callable(member) and member.__doc__: @@ -80,7 +84,7 @@ def add_deprecation_warnings(): if m: reason = m.group(1) or "" msg = f"{f3d_class.__qualname__}.{name} is deprecated{reason}" - setattr(f3d_class, name, deprecated_decorator(member, msg)) + setattr(f3d_class, name, _deprecated_decorator(member, msg)) -add_deprecation_warnings() +_add_deprecation_warnings() diff --git a/python/generate_stubs.py b/python/generate_stubs.py new file mode 100644 index 0000000000..71891b75a4 --- /dev/null +++ b/python/generate_stubs.py @@ -0,0 +1,96 @@ +import re +import subprocess +import sys +from argparse import ArgumentParser +from contextlib import contextmanager +from difflib import unified_diff +from pathlib import Path +from tempfile import gettempdir +from typing import Iterable + + +def main(): + argparser = ArgumentParser() + argparser.add_argument( + "-o", + "--into", + help="output directory for the post-processed stubs (default: %(default)s)", + default=f"{gettempdir()}/stubs", + ) + args = argparser.parse_args() + + stubs = run_pybind11_stubgen(Path(args.into)) + if diff := postprocess_generated_stubs(stubs): + print("\n".join(diff)) + + +def run_pybind11_stubgen(out_dir: Path, module: str = "f3d"): + stubgen_cmd = ( + # use current python interpreter to run stubs generation for the `f3d` module + *(sys.executable, "-m", "pybind11_stubgen", module), + # fix enum for default values in `Image.save()` and `Image.save_buffer()` + *("--enum-class-locations", "SaveFormat:Image"), + # ignore `f3d.vector3_t` and `f3d.point3_t` as we dont actually map them + # but let them auto-convert from and to `tuple[float, float, float]` + # (all occurrences will be postprocessed later) + *("--ignore-unresolved-names", r"f3d\.(vector3_t|point3_t)"), + # output directory so we can retrieve and post process + *("--output-dir", out_dir), + ) + with retrieve_changed_files(out_dir, f"{module}/**/*.pyi") as changed_files: + subprocess.check_call(stubgen_cmd) + return changed_files + + +def postprocess_generated_stubs(filenames: Iterable[Path]): + replacements = [ + ( + # change `point3_t` and `vector3_t` parameter annotations and return types + # to `tuple[float, float, float]` + r"((:|->)\s*)f3d\.(vector3_t|point3_t)", + r"\1tuple[float, float, float]", + ), + ( + # add missing template parameter to raw `os.PathLike` (`os.PathLike[str]`) + r"(PathLike)(?!\[)", + r"\1[str]", + ), + ( + # remove `_pybind11_conduit_v1_` static methods + r"^\s+@staticmethod\s+def _pybind11_conduit_v1_\(\*args, *\*\*kwargs\):\s*\.\.\.[\n\r]", + "", + ), + ] + + diff: list[str] = [] + + for filename in filenames: + processed = original = filename.read_text() + for pattern, repl in replacements: + processed = re.sub(pattern, repl, processed, flags=re.MULTILINE) + filename.write_text(processed) + + diff += unified_diff( + original.splitlines(), + processed.splitlines(), + str(filename), + str(filename), + n=1, + lineterm="", + ) + + return diff + + +@contextmanager +def retrieve_changed_files(directory: Path, files_glob: str): + mtimes = {f: f.stat().st_mtime for f in directory.glob(files_glob)} + changed_files: list[Path] = [] + yield changed_files + changed_files += ( + f for f in directory.glob(files_glob) if f.stat().st_mtime > mtimes.get(f, 0) + ) + + +if __name__ == "__main__": + main()