diff --git a/CMakeLists.txt b/CMakeLists.txt index 9f39c20197..5679f1942c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -308,7 +308,12 @@ else () set (_py_dev_found Python3_Development.Module_FOUND) endif () if (USE_PYTHON AND ${_py_dev_found} AND NOT BUILD_OIIOUTIL_ONLY) - add_subdirectory (src/python) + if (OIIO_BUILD_PYTHON_PYBIND11) + add_subdirectory (src/python) + endif () + if (OIIO_BUILD_PYTHON_NANOBIND) + add_subdirectory (src/python-nanobind) + endif () else () message (STATUS "Not building Python bindings: USE_PYTHON=${USE_PYTHON}, Python3_Development.Module_FOUND=${Python3_Development.Module_FOUND}") endif () diff --git a/INSTALL.md b/INSTALL.md index 32582750f9..12cf573334 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -42,6 +42,9 @@ NEW or CHANGED MINIMUM dependencies since the last major release are **bold**. * Python >= 3.9 (tested through 3.13). * pybind11 >= 2.7 (tested through 3.0) * NumPy (tested through 2.2.4) + * For the nanobind (WIP) migration backend used in source/CMake builds: + * nanobind discoverable by CMake, or installed in the active Python + environment so `python -m nanobind --cmake_dir` works * If you want support for PNG files: * libPNG >= 1.6.0 (tested though 1.6.50) * If you want support for camera "RAW" formats: @@ -157,6 +160,12 @@ Make wrapper (`make PkgName_ROOT=...`). `USE_PYTHON=0` : Omits building the Python bindings. +`OIIO_PYTHON_BINDINGS_BACKEND=pybind11|nanobind|both` : Select which Python +binding backend(s) to configure for source/CMake builds. `both` keeps the +existing pybind11 module and also builds the nanobind (WIP) module. The +Python packaging path driven by `pyproject.toml` still targets the production +pybind11 bindings today. + `OIIO_BUILD_TESTS=0` : Omits building tests (you probably don't need them unless you are a developer of OIIO or want to verify that your build passes all tests). @@ -247,6 +256,7 @@ Additionally, a few helpful modifiers alter some build-time options: | make USE_QT=0 ... | Skip anything that needs Qt | | make MYCC=xx MYCXX=yy ... | Use custom compilers | | make USE_PYTHON=0 ... | Don't build the Python binding | +| make OIIO_PYTHON_BINDINGS_BACKEND=both ... | For source/CMake builds, build the existing pybind11 bindings and the nanobind (WIP) module | | make BUILD_SHARED_LIBS=0 | Build static library instead of shared | | make IGNORE_HOMEBREWED_DEPS=1 | Ignore homebrew-managed dependencies | | make LINKSTATIC=1 ... | Link with static external libraries when possible | diff --git a/src/cmake/externalpackages.cmake b/src/cmake/externalpackages.cmake index cb366b2a55..c5eabd749b 100644 --- a/src/cmake/externalpackages.cmake +++ b/src/cmake/externalpackages.cmake @@ -118,9 +118,13 @@ endif() if (USE_PYTHON) find_python() endif () -if (USE_PYTHON) +if (USE_PYTHON AND OIIO_BUILD_PYTHON_PYBIND11) checked_find_package (pybind11 REQUIRED VERSION_MIN 2.7) endif () +if (USE_PYTHON AND OIIO_BUILD_PYTHON_NANOBIND) + discover_nanobind_cmake_dir() + checked_find_package (nanobind CONFIG REQUIRED) +endif () ########################################################################### diff --git a/src/cmake/pythonutils.cmake b/src/cmake/pythonutils.cmake index efab6ea63d..02a945e592 100644 --- a/src/cmake/pythonutils.cmake +++ b/src/cmake/pythonutils.cmake @@ -8,6 +8,31 @@ set (PYTHON_VERSION "" CACHE STRING "Target version of python to find") option (PYLIB_INCLUDE_SONAME "If ON, soname/soversion will be set for Python module library" OFF) option (PYLIB_LIB_PREFIX "If ON, prefix the Python module with 'lib'" OFF) set (PYMODULE_SUFFIX "" CACHE STRING "Suffix to add to Python module init namespace") +set (OIIO_PYTHON_BINDINGS_BACKEND "pybind11" CACHE STRING + "Which Python binding backend(s) to build: pybind11, nanobind, or both") +set_property (CACHE OIIO_PYTHON_BINDINGS_BACKEND PROPERTY STRINGS + pybind11 nanobind both) + +# Normalize and validate the user-facing backend selector early so the rest +# of the file can make simple boolean decisions. +string (TOLOWER "${OIIO_PYTHON_BINDINGS_BACKEND}" OIIO_PYTHON_BINDINGS_BACKEND) +if (NOT OIIO_PYTHON_BINDINGS_BACKEND MATCHES "^(pybind11|nanobind|both)$") + message (FATAL_ERROR + "OIIO_PYTHON_BINDINGS_BACKEND must be one of: pybind11, nanobind, both") +endif () + +# Derive internal switches used by the top-level CMakeLists and the Python +# helper macros below. +set (OIIO_BUILD_PYTHON_PYBIND11 OFF) +set (OIIO_BUILD_PYTHON_NANOBIND OFF) +if (OIIO_PYTHON_BINDINGS_BACKEND STREQUAL "pybind11" + OR OIIO_PYTHON_BINDINGS_BACKEND STREQUAL "both") + set (OIIO_BUILD_PYTHON_PYBIND11 ON) +endif () +if (OIIO_PYTHON_BINDINGS_BACKEND STREQUAL "nanobind" + OR OIIO_PYTHON_BINDINGS_BACKEND STREQUAL "both") + set (OIIO_BUILD_PYTHON_NANOBIND ON) +endif () if (WIN32) set (PYLIB_LIB_TYPE SHARED CACHE STRING "Type of library to build for python module (MODULE or SHARED)") else () @@ -54,6 +79,15 @@ macro (find_python) Python3_Development.Module_FOUND Python3_Interpreter_FOUND ) + if (OIIO_BUILD_PYTHON_NANOBIND) + # nanobind's CMake package expects the generic FindPython targets and + # variables (Python::Module, Python_EXECUTABLE, etc.), not the + # versioned Python3::* targets that the rest of OIIO uses today. + find_package (Python ${Python3_VERSION_MAJOR}.${Python3_VERSION_MINOR} + EXACT REQUIRED + COMPONENTS ${_py_components}) + endif () + # The version that was found may not be the default or user # defined one. set (PYTHON_VERSION_FOUND ${Python3_VERSION_MAJOR}.${Python3_VERSION_MINOR}) @@ -63,15 +97,44 @@ macro (find_python) set (PythonInterp3_FIND_VERSION PYTHON_VERSION_FOUND) set (PythonInterp3_FIND_VERSION_MAJOR ${Python3_VERSION_MAJOR}) + if (NOT DEFINED PYTHON_SITE_ROOT_DIR) + set (PYTHON_SITE_ROOT_DIR + "${CMAKE_INSTALL_LIBDIR}/python${PYTHON_VERSION_FOUND}/site-packages") + endif () if (NOT DEFINED PYTHON_SITE_DIR) - set (PYTHON_SITE_DIR "${CMAKE_INSTALL_LIBDIR}/python${PYTHON_VERSION_FOUND}/site-packages/OpenImageIO") + set (PYTHON_SITE_DIR "${PYTHON_SITE_ROOT_DIR}/OpenImageIO") endif () message (VERBOSE " Python site packages dir ${PYTHON_SITE_DIR}") + message (VERBOSE " Python site packages root ${PYTHON_SITE_ROOT_DIR}") message (VERBOSE " Python to include 'lib' prefix: ${PYLIB_LIB_PREFIX}") message (VERBOSE " Python to include SO version: ${PYLIB_INCLUDE_SONAME}") endmacro() +# Help CMake locate nanobind when it was installed as a Python package. +macro (discover_nanobind_cmake_dir) + if (nanobind_DIR OR nanobind_ROOT OR "$ENV{nanobind_DIR}" OR "$ENV{nanobind_ROOT}") + return() + endif () + + if (NOT Python3_Interpreter_FOUND) + return() + endif () + + execute_process ( + COMMAND ${Python3_EXECUTABLE} -m nanobind --cmake_dir + RESULT_VARIABLE _oiio_nanobind_result + OUTPUT_VARIABLE _oiio_nanobind_cmake_dir + OUTPUT_STRIP_TRAILING_WHITESPACE + ERROR_QUIET) + if (_oiio_nanobind_result EQUAL 0 + AND EXISTS "${_oiio_nanobind_cmake_dir}/nanobind-config.cmake") + set (nanobind_DIR "${_oiio_nanobind_cmake_dir}" CACHE PATH + "Path to the nanobind CMake package" FORCE) + endif () +endmacro() + + ########################################################################### # pybind11 @@ -163,3 +226,62 @@ macro (setup_python_module) endmacro () + +########################################################################### +# nanobind + +macro (setup_python_module_nanobind) + cmake_parse_arguments (lib "" "TARGET;MODULE" + "SOURCES;LIBS;INCLUDES;SYSTEM_INCLUDE_DIRS;PACKAGE_FILES" + ${ARGN}) + + set (target_name ${lib_TARGET}) + + if (NOT COMMAND nanobind_add_module) + discover_nanobind_cmake_dir() + find_package (nanobind CONFIG REQUIRED) + endif () + + nanobind_add_module(${target_name} ${lib_SOURCES}) + if (CMAKE_CXX_COMPILER_ID MATCHES "Clang" AND TARGET nanobind-static) + target_compile_options (nanobind-static PRIVATE -Wno-error=format-nonliteral) + endif () + + target_include_directories (${target_name} + PRIVATE ${lib_INCLUDES}) + target_include_directories (${target_name} + SYSTEM PRIVATE ${lib_SYSTEM_INCLUDE_DIRS}) + target_link_libraries (${target_name} + PRIVATE ${lib_LIBS}) + + set (_module_LINK_FLAGS "${VISIBILITY_MAP_COMMAND} ${EXTRA_DSO_LINK_ARGS}") + if (UNIX AND NOT APPLE) + set (_module_LINK_FLAGS "${_module_LINK_FLAGS} -Wl,--exclude-libs,ALL") + endif () + set_target_properties (${target_name} PROPERTIES + LINK_FLAGS ${_module_LINK_FLAGS} + OUTPUT_NAME ${lib_MODULE} + DEBUG_POSTFIX "") + + if (SKBUILD) + set (_nanobind_install_dir .) + else () + set (_nanobind_install_dir ${PYTHON_SITE_DIR}) + endif () + + # Keep nanobind modules isolated in the build tree so they don't alter + # how the existing top-level OpenImageIO module is imported during tests. + set_target_properties (${target_name} PROPERTIES + LIBRARY_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/lib/python/nanobind/OpenImageIO + ARCHIVE_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/lib/python/nanobind/OpenImageIO + ) + + install (TARGETS ${target_name} + RUNTIME DESTINATION ${_nanobind_install_dir} COMPONENT user + LIBRARY DESTINATION ${_nanobind_install_dir} COMPONENT user) + + if (lib_PACKAGE_FILES) + install (FILES ${lib_PACKAGE_FILES} + DESTINATION ${_nanobind_install_dir} COMPONENT user) + endif () +endmacro () diff --git a/src/python-nanobind/CMakeLists.txt b/src/python-nanobind/CMakeLists.txt new file mode 100644 index 0000000000..44f8ca544d --- /dev/null +++ b/src/python-nanobind/CMakeLists.txt @@ -0,0 +1,27 @@ +# Copyright Contributors to the OpenImageIO project. +# SPDX-License-Identifier: Apache-2.0 +# https://github.com/AcademySoftwareFoundation/OpenImageIO + +set (nanobind_srcs + py_oiio.cpp + py_roi.cpp + py_imagespec.cpp + py_typedesc.cpp) + +set (nanobind_build_package_dir ${CMAKE_BINARY_DIR}/lib/python/nanobind/OpenImageIO) +file (MAKE_DIRECTORY ${nanobind_build_package_dir}) +configure_file (__init__.py + ${nanobind_build_package_dir}/__init__.py + COPYONLY) + +setup_python_module_nanobind ( + TARGET PyOpenImageIONanobind + MODULE _OpenImageIO + SOURCES ${nanobind_srcs} + LIBS OpenImageIO +) + +if (OIIO_PYTHON_BINDINGS_BACKEND STREQUAL "nanobind") + install (FILES __init__.py + DESTINATION ${PYTHON_SITE_DIR} COMPONENT user) +endif () diff --git a/src/python-nanobind/__init__.py b/src/python-nanobind/__init__.py new file mode 100644 index 0000000000..c7f5ccbabf --- /dev/null +++ b/src/python-nanobind/__init__.py @@ -0,0 +1,37 @@ +# Copyright Contributors to the OpenImageIO project. +# SPDX-License-Identifier: Apache-2.0 +# https://github.com/AcademySoftwareFoundation/OpenImageIO + +import os +import sys +import platform + +_here = os.path.abspath(os.path.dirname(__file__)) + +# Set $OpenImageIO_ROOT if not already set before importing helper modules. +if not os.getenv("OpenImageIO_ROOT"): + if all([os.path.exists(os.path.join(_here, i)) for i in ["share", "bin", "lib"]]): + os.environ["OpenImageIO_ROOT"] = _here + +if platform.system() == "Windows": + _bin_dir = os.path.join(_here, "bin") + if os.path.exists(_bin_dir): + os.add_dll_directory(_bin_dir) + elif sys.version_info >= (3, 8): + if os.getenv("OPENIMAGEIO_PYTHON_LOAD_DLLS_FROM_PATH", "0") == "1": + for path in os.getenv("PATH", "").split(os.pathsep): + if os.path.exists(path) and path != ".": + os.add_dll_directory(path) + +from . import _OpenImageIO as _ext # noqa: E402 +from ._OpenImageIO import * # type: ignore # noqa: E402, F401, F403 + +__doc__ = """ +OpenImageIO Python package exposing the nanobind migration bindings. +The production pybind11 bindings are not installed in this configuration. +"""[1:-1] + +__version__ = getattr(_ext, "__version__", "") + +# TODO: Restore the Python CLI entry-point trampolines when the nanobind +# package ships the full wheel-style bin/lib/share layout. diff --git a/src/python-nanobind/py_imagespec.cpp b/src/python-nanobind/py_imagespec.cpp new file mode 100644 index 0000000000..a911f087df --- /dev/null +++ b/src/python-nanobind/py_imagespec.cpp @@ -0,0 +1,78 @@ +// Copyright Contributors to the OpenImageIO project. +// SPDX-License-Identifier: Apache-2.0 +// https://github.com/AcademySoftwareFoundation/OpenImageIO + +#include "py_oiio.h" + +namespace { + +OIIO_NAMESPACE_USING + +ROI +imagespec_get_roi(const ImageSpec& spec) +{ + return get_roi(spec); +} + + +ROI +imagespec_get_roi_full(const ImageSpec& spec) +{ + return get_roi_full(spec); +} + + +void +imagespec_set_roi(ImageSpec& spec, const ROI& roi) +{ + set_roi(spec, roi); +} + + +void +imagespec_set_roi_full(ImageSpec& spec, const ROI& roi) +{ + set_roi_full(spec, roi); +} + +} // namespace + + +namespace PyOpenImageIO { + +void +declare_imagespec(nb::module_& m) +{ + // This is intentionally not a full ImageSpec port yet. It only exists + // to support ROI parity until py_imagespec.cpp grows into the real + // binding. + nb::class_(m, "ImageSpec") + .def(nb::init<>()) + .def("__init__", + [](ImageSpec* self, int xres, int yres, int nchans, + const TypeDesc& format) { + new (self) ImageSpec(xres, yres, nchans, format); + }) + .def_rw("x", &ImageSpec::x) + .def_rw("y", &ImageSpec::y) + .def_rw("z", &ImageSpec::z) + .def_rw("width", &ImageSpec::width) + .def_rw("height", &ImageSpec::height) + .def_rw("depth", &ImageSpec::depth) + .def_rw("full_x", &ImageSpec::full_x) + .def_rw("full_y", &ImageSpec::full_y) + .def_rw("full_z", &ImageSpec::full_z) + .def_rw("full_width", &ImageSpec::full_width) + .def_rw("full_height", &ImageSpec::full_height) + .def_rw("full_depth", &ImageSpec::full_depth) + .def_rw("nchannels", &ImageSpec::nchannels) + .def_prop_ro("roi", &imagespec_get_roi) + .def_prop_ro("roi_full", &imagespec_get_roi_full); + + m.def("get_roi", &imagespec_get_roi); + m.def("get_roi_full", &imagespec_get_roi_full); + m.def("set_roi", &imagespec_set_roi); + m.def("set_roi_full", &imagespec_set_roi_full); +} + +} // namespace PyOpenImageIO diff --git a/src/python-nanobind/py_oiio.cpp b/src/python-nanobind/py_oiio.cpp new file mode 100644 index 0000000000..cded9c62a7 --- /dev/null +++ b/src/python-nanobind/py_oiio.cpp @@ -0,0 +1,16 @@ +// Copyright Contributors to the OpenImageIO project. +// SPDX-License-Identifier: Apache-2.0 +// https://github.com/AcademySoftwareFoundation/OpenImageIO + +#include "py_oiio.h" + + +NB_MODULE(_OpenImageIO, m) +{ + m.doc() = "OpenImageIO nanobind bindings."; + + PyOpenImageIO::declare_typedesc(m); + PyOpenImageIO::declare_roi(m); + PyOpenImageIO::declare_imagespec(m); + m.attr("__version__") = OIIO_VERSION_STRING; +} diff --git a/src/python-nanobind/py_oiio.h b/src/python-nanobind/py_oiio.h new file mode 100644 index 0000000000..e3335397bc --- /dev/null +++ b/src/python-nanobind/py_oiio.h @@ -0,0 +1,31 @@ +// Copyright Contributors to the OpenImageIO project. +// SPDX-License-Identifier: Apache-2.0 +// https://github.com/AcademySoftwareFoundation/OpenImageIO + +#pragma once + +#include +#include +#include +#include + +#include +#include +#include +#include + +namespace nb = nanobind; +using namespace nb::literals; + +namespace PyOpenImageIO { + +OIIO_NAMESPACE_USING + +void +declare_roi(nb::module_& m); +void +declare_imagespec(nb::module_& m); +void +declare_typedesc(nb::module_& m); + +} // namespace PyOpenImageIO diff --git a/src/python-nanobind/py_roi.cpp b/src/python-nanobind/py_roi.cpp new file mode 100644 index 0000000000..8d85933fb1 --- /dev/null +++ b/src/python-nanobind/py_roi.cpp @@ -0,0 +1,66 @@ +// Copyright Contributors to the OpenImageIO project. +// SPDX-License-Identifier: Apache-2.0 +// https://github.com/AcademySoftwareFoundation/OpenImageIO + +#include "py_oiio.h" + +namespace { + +OIIO_NAMESPACE_USING + +bool +roi_contains_coord(const ROI& roi, int x, int y, int z, int ch) +{ + return roi.contains(x, y, z, ch); +} + + +bool +roi_contains_roi(const ROI& roi, const ROI& other) +{ + return roi.contains(other); +} + +} // namespace + + +namespace PyOpenImageIO { + +void +declare_roi(nb::module_& m) +{ + nb::class_ roi(m, "ROI"); + roi.def_rw("xbegin", &ROI::xbegin) + .def_rw("xend", &ROI::xend) + .def_rw("ybegin", &ROI::ybegin) + .def_rw("yend", &ROI::yend) + .def_rw("zbegin", &ROI::zbegin) + .def_rw("zend", &ROI::zend) + .def_rw("chbegin", &ROI::chbegin) + .def_rw("chend", &ROI::chend) + .def(nb::init<>()) + .def(nb::init()) + .def(nb::init()) + .def(nb::init()) + .def(nb::init()) + .def_prop_ro("defined", &ROI::defined) + .def_prop_ro("width", &ROI::width) + .def_prop_ro("height", &ROI::height) + .def_prop_ro("depth", &ROI::depth) + .def_prop_ro("nchannels", &ROI::nchannels) + .def_prop_ro("npixels", &ROI::npixels) + .def("contains", &roi_contains_coord, "x"_a, "y"_a, "z"_a = 0, + "ch"_a = 0) + .def("contains", &roi_contains_roi, "other"_a) + .def_prop_ro_static("All", [](nb::handle) { return ROI::All(); }) + .def("__str__", + [](const ROI& roi_) { return Strutil::fmt::format("{}", roi_); }) + .def("copy", [](const ROI& self) { return self; }) + .def(nb::self == nb::self) + .def(nb::self != nb::self); + + m.def("union", &roi_union); + m.def("intersection", &roi_intersection); +} + +} // namespace PyOpenImageIO diff --git a/src/python-nanobind/py_typedesc.cpp b/src/python-nanobind/py_typedesc.cpp new file mode 100644 index 0000000000..7196435839 --- /dev/null +++ b/src/python-nanobind/py_typedesc.cpp @@ -0,0 +1,246 @@ +// Copyright Contributors to the OpenImageIO project. +// SPDX-License-Identifier: Apache-2.0 +// https://github.com/AcademySoftwareFoundation/OpenImageIO + +#include "py_oiio.h" + +namespace { + +OIIO_NAMESPACE_USING + +template +void +typedesc_property(TypeDesc& t, Enum value); + +template<> +void +typedesc_property(TypeDesc& t, TypeDesc::BASETYPE value) +{ + t.basetype = value; +} + +template<> +void +typedesc_property(TypeDesc& t, TypeDesc::AGGREGATE value) +{ + t.aggregate = value; +} + +template<> +void +typedesc_property(TypeDesc& t, + TypeDesc::VECSEMANTICS value) +{ + t.vecsemantics = value; +} + +} // namespace + + +namespace PyOpenImageIO { + +void +declare_typedesc(nb::module_& m) +{ + using BASETYPE = TypeDesc::BASETYPE; + using AGGREGATE = TypeDesc::AGGREGATE; + using VECSEMANTICS = TypeDesc::VECSEMANTICS; + + nb::enum_(m, "BASETYPE") + .value("UNKNOWN", TypeDesc::UNKNOWN) + .value("NONE", TypeDesc::NONE) + .value("UCHAR", TypeDesc::UCHAR) + .value("UINT8", TypeDesc::UINT8) + .value("CHAR", TypeDesc::CHAR) + .value("INT8", TypeDesc::INT8) + .value("UINT16", TypeDesc::UINT16) + .value("USHORT", TypeDesc::USHORT) + .value("SHORT", TypeDesc::SHORT) + .value("INT16", TypeDesc::INT16) + .value("UINT", TypeDesc::UINT) + .value("UINT32", TypeDesc::UINT32) + .value("INT", TypeDesc::INT) + .value("INT32", TypeDesc::INT32) + .value("ULONGLONG", TypeDesc::ULONGLONG) + .value("UINT64", TypeDesc::UINT64) + .value("LONGLONG", TypeDesc::LONGLONG) + .value("INT64", TypeDesc::INT64) + .value("HALF", TypeDesc::HALF) + .value("FLOAT", TypeDesc::FLOAT) + .value("DOUBLE", TypeDesc::DOUBLE) + .value("STRING", TypeDesc::STRING) + .value("PTR", TypeDesc::PTR) + .value("LASTBASE", TypeDesc::LASTBASE) + .export_values(); + + nb::enum_(m, "AGGREGATE") + .value("SCALAR", TypeDesc::SCALAR) + .value("VEC2", TypeDesc::VEC2) + .value("VEC3", TypeDesc::VEC3) + .value("VEC4", TypeDesc::VEC4) + .value("MATRIX33", TypeDesc::MATRIX33) + .value("MATRIX44", TypeDesc::MATRIX44) + .export_values(); + + nb::enum_(m, "VECSEMANTICS") + .value("NOXFORM", TypeDesc::NOXFORM) + .value("NOSEMANTICS", TypeDesc::NOSEMANTICS) + .value("COLOR", TypeDesc::COLOR) + .value("POINT", TypeDesc::POINT) + .value("VECTOR", TypeDesc::VECTOR) + .value("NORMAL", TypeDesc::NORMAL) + .value("TIMECODE", TypeDesc::TIMECODE) + .value("KEYCODE", TypeDesc::KEYCODE) + .value("RATIONAL", TypeDesc::RATIONAL) + .value("BOX", TypeDesc::BOX) + .export_values(); + + nb::class_(m, "TypeDesc") + .def_prop_rw( + "basetype", [](TypeDesc t) { return BASETYPE(t.basetype); }, + [](TypeDesc& t, BASETYPE b) { typedesc_property(t, b); }) + .def_prop_rw( + "aggregate", [](TypeDesc t) { return AGGREGATE(t.aggregate); }, + [](TypeDesc& t, AGGREGATE b) { typedesc_property(t, b); }) + .def_prop_rw( + "vecsemantics", + [](TypeDesc t) { return VECSEMANTICS(t.vecsemantics); }, + [](TypeDesc& t, VECSEMANTICS b) { typedesc_property(t, b); }) + .def_rw("arraylen", &TypeDesc::arraylen) + .def(nb::init<>()) + .def(nb::init()) + .def(nb::init()) + .def(nb::init()) + .def(nb::init()) + .def(nb::init()) + .def(nb::init()) + .def("c_str", + [](const TypeDesc& self) { return std::string(self.c_str()); }) + .def("numelements", &TypeDesc::numelements) + .def("basevalues", &TypeDesc::basevalues) + .def("size", &TypeDesc::size) + .def("elementtype", &TypeDesc::elementtype) + .def("elementsize", &TypeDesc::elementsize) + .def("basesize", &TypeDesc::basesize) + .def("fromstring", + [](TypeDesc& t, const char* typestring) { + t.fromstring(typestring); + }) + .def("equivalent", &TypeDesc::equivalent) + .def("unarray", &TypeDesc::unarray) + .def("is_vec2", + [](const TypeDesc& t, BASETYPE b = TypeDesc::FLOAT) { + return t.is_vec2(b); + }) + .def("is_vec3", + [](const TypeDesc& t, BASETYPE b = TypeDesc::FLOAT) { + return t.is_vec3(b); + }) + .def("is_vec4", + [](const TypeDesc& t, BASETYPE b = TypeDesc::FLOAT) { + return t.is_vec4(b); + }) + .def("is_box2", + [](const TypeDesc& t, BASETYPE b = TypeDesc::FLOAT) { + return t.is_box2(b); + }) + .def("is_box3", + [](const TypeDesc& t, BASETYPE b = TypeDesc::FLOAT) { + return t.is_box3(b); + }) + .def_static("all_types_equal", + [](const std::vector& types) { + return TypeDesc::all_types_equal(types); + }) + .def(nb::self == nb::self) + .def(nb::self != nb::self) + .def("__str__", [](TypeDesc t) { return std::string(t.c_str()); }) + .def("__repr__", [](TypeDesc t) { + return Strutil::fmt::format("", t.c_str()); + }); + + nb::implicitly_convertible(); + nb::implicitly_convertible(); + + m.attr("UNKNOWN") = nb::cast(TypeDesc::UNKNOWN); + m.attr("NONE") = nb::cast(TypeDesc::NONE); + m.attr("UCHAR") = nb::cast(TypeDesc::UCHAR); + m.attr("UINT8") = nb::cast(TypeDesc::UINT8); + m.attr("CHAR") = nb::cast(TypeDesc::CHAR); + m.attr("INT8") = nb::cast(TypeDesc::INT8); + m.attr("UINT16") = nb::cast(TypeDesc::UINT16); + m.attr("USHORT") = nb::cast(TypeDesc::USHORT); + m.attr("SHORT") = nb::cast(TypeDesc::SHORT); + m.attr("INT16") = nb::cast(TypeDesc::INT16); + m.attr("UINT") = nb::cast(TypeDesc::UINT); + m.attr("UINT32") = nb::cast(TypeDesc::UINT32); + m.attr("INT") = nb::cast(TypeDesc::INT); + m.attr("INT32") = nb::cast(TypeDesc::INT32); + m.attr("ULONGLONG") = nb::cast(TypeDesc::ULONGLONG); + m.attr("UINT64") = nb::cast(TypeDesc::UINT64); + m.attr("LONGLONG") = nb::cast(TypeDesc::LONGLONG); + m.attr("INT64") = nb::cast(TypeDesc::INT64); + m.attr("HALF") = nb::cast(TypeDesc::HALF); + m.attr("FLOAT") = nb::cast(TypeDesc::FLOAT); + m.attr("DOUBLE") = nb::cast(TypeDesc::DOUBLE); + m.attr("STRING") = nb::cast(TypeDesc::STRING); + m.attr("PTR") = nb::cast(TypeDesc::PTR); + m.attr("LASTBASE") = nb::cast(TypeDesc::LASTBASE); + + m.attr("SCALAR") = nb::cast(TypeDesc::SCALAR); + m.attr("VEC2") = nb::cast(TypeDesc::VEC2); + m.attr("VEC3") = nb::cast(TypeDesc::VEC3); + m.attr("VEC4") = nb::cast(TypeDesc::VEC4); + m.attr("MATRIX33") = nb::cast(TypeDesc::MATRIX33); + m.attr("MATRIX44") = nb::cast(TypeDesc::MATRIX44); + + m.attr("NOXFORM") = nb::cast(TypeDesc::NOXFORM); + m.attr("NOSEMANTICS") = nb::cast(TypeDesc::NOSEMANTICS); + m.attr("COLOR") = nb::cast(TypeDesc::COLOR); + m.attr("POINT") = nb::cast(TypeDesc::POINT); + m.attr("VECTOR") = nb::cast(TypeDesc::VECTOR); + m.attr("NORMAL") = nb::cast(TypeDesc::NORMAL); + m.attr("TIMECODE") = nb::cast(TypeDesc::TIMECODE); + m.attr("KEYCODE") = nb::cast(TypeDesc::KEYCODE); + m.attr("RATIONAL") = nb::cast(TypeDesc::RATIONAL); + m.attr("BOX") = nb::cast(TypeDesc::BOX); + + m.attr("TypeUnknown") = TypeUnknown; + m.attr("TypeFloat") = TypeFloat; + m.attr("TypeColor") = TypeColor; + m.attr("TypePoint") = TypePoint; + m.attr("TypeVector") = TypeVector; + m.attr("TypeNormal") = TypeNormal; + m.attr("TypeString") = TypeString; + m.attr("TypeInt") = TypeInt; + m.attr("TypeUInt") = TypeUInt; + m.attr("TypeInt64") = TypeInt64; + m.attr("TypeUInt64") = TypeUInt64; + m.attr("TypeInt32") = TypeInt32; + m.attr("TypeUInt32") = TypeUInt32; + m.attr("TypeInt16") = TypeInt16; + m.attr("TypeUInt16") = TypeUInt16; + m.attr("TypeInt8") = TypeInt8; + m.attr("TypeUInt8") = TypeUInt8; + m.attr("TypeHalf") = TypeHalf; + m.attr("TypeMatrix") = TypeMatrix; + m.attr("TypeMatrix33") = TypeMatrix33; + m.attr("TypeMatrix44") = TypeMatrix44; + m.attr("TypeTimeCode") = TypeTimeCode; + m.attr("TypeKeyCode") = TypeKeyCode; + m.attr("TypeFloat2") = TypeFloat2; + m.attr("TypeVector2") = TypeVector2; + m.attr("TypeFloat4") = TypeFloat4; + m.attr("TypeVector4") = TypeVector4; + m.attr("TypeVector2i") = TypeVector2i; + m.attr("TypeVector3i") = TypeVector3i; + m.attr("TypeBox2") = TypeBox2; + m.attr("TypeBox3") = TypeBox3; + m.attr("TypeBox2i") = TypeBox2i; + m.attr("TypeBox3i") = TypeBox3i; + m.attr("TypeRational") = TypeRational; + m.attr("TypeURational") = TypeURational; + m.attr("TypePointer") = TypePointer; +} + +} // namespace PyOpenImageIO diff --git a/testsuite/python-roi/ref/out.txt b/testsuite/python-roi/ref/out.txt index a650c974a2..c3a4e10207 100644 --- a/testsuite/python-roi/ref/out.txt +++ b/testsuite/python-roi/ref/out.txt @@ -30,6 +30,10 @@ r contains (10,10) (expect yes): True r contains (1000,10) (expect no): False r contains roi(10,20,10,20,0,1,0,1) (expect yes): True r contains roi(1010,1020,10,20,0,1,0,1) (expect no): False +ROI(0, 10, 0, 10, 2, 4) = 0 10 0 10 2 4 0 10000 +r5 contains (1,1,2,1) (expect yes): True +r5 contains (1,1,1,1) (expect no): False +r5 contains (1,1,2,3) (expect no): False A = 0 10 0 8 0 1 0 4 B = 5 15 -1 10 0 1 0 4 ROI.union(A,B) = 0 15 -1 10 0 1 0 4 diff --git a/testsuite/python-roi/src/test_roi.py b/testsuite/python-roi/src/test_roi.py index 3534e11458..f59ac07548 100755 --- a/testsuite/python-roi/src/test_roi.py +++ b/testsuite/python-roi/src/test_roi.py @@ -9,9 +9,6 @@ import OpenImageIO as oiio - - - ###################################################################### # main test starts here @@ -57,6 +54,14 @@ print ("r contains (1000,10) (expect no): ", r.contains(1000,10)) print ("r contains roi(10,20,10,20,0,1,0,1) (expect yes): ", r.contains(oiio.ROI(10,20,10,20,0,1,0,1))) print ("r contains roi(1010,1020,10,20,0,1,0,1) (expect no): ", r.contains(oiio.ROI(1010,1020,10,20,0,1,0,1))) + # Cover the 6-argument ROI constructor and the contains(x, y, z, ch) + # overload with explicit z/channel arguments. + r4 = oiio.ROI (0, 10, 0, 10, 2, 4) + print ("ROI(0, 10, 0, 10, 2, 4) =", r4) + r5 = oiio.ROI (0, 10, 0, 10, 2, 4, 1, 3) + print ("r5 contains (1,1,2,1) (expect yes): ", r5.contains(1,1,2,1)) + print ("r5 contains (1,1,1,1) (expect no): ", r5.contains(1,1,1,1)) + print ("r5 contains (1,1,2,3) (expect no): ", r5.contains(1,1,2,3)) A = oiio.ROI (0, 10, 0, 8, 0, 1, 0, 4) B = oiio.ROI (5, 15, -1, 10, 0, 1, 0, 4) @@ -84,4 +89,3 @@ print ("Done.") except Exception as detail: print ("Unknown exception:", detail) - diff --git a/testsuite/python-typedesc/ref/out.txt b/testsuite/python-typedesc/ref/out.txt index 723eb9ba33..0a5f329fe5 100644 --- a/testsuite/python-typedesc/ref/out.txt +++ b/testsuite/python-typedesc/ref/out.txt @@ -159,6 +159,31 @@ equivalent(vector,color) True vector.equivalent(float) False equivalent(vector,float) False +type 'mutated FLOAT, VEC3, COLOR, array of 2' + c_str "color[2]" + basetype BASETYPE.FLOAT + aggregate AGGREGATE.VEC3 + vecsemantics VECSEMANTICS.COLOR + arraylen 2 + str(t) = "color[2]" + size = 24 + elementtype = color + numelements = 2 + basevalues = 6 + elementsize = 12 + basesize = 4 +type 'fromstring('point')' + c_str "point" +after unarray('float[2]') = float +vector is_vec2,is_vec3,is_vec4 = False True False +box2i is_box2,is_box3 = True False +all_types_equal([uint8,uint8]) = True +all_types_equal([uint8,uint16]) = False +repr(TypeFloat) = + +implicit enum ImageSpec roi = 0 8 0 9 0 1 0 3 +implicit str ImageSpec roi = 0 8 0 9 0 1 0 3 + type 'TypeFloat' c_str "float" type 'TypeColor' diff --git a/testsuite/python-typedesc/run.py b/testsuite/python-typedesc/run.py index 180b4a6d01..a40c33abb8 100755 --- a/testsuite/python-typedesc/run.py +++ b/testsuite/python-typedesc/run.py @@ -4,6 +4,4 @@ # SPDX-License-Identifier: Apache-2.0 # https://github.com/AcademySoftwareFoundation/OpenImageIO - command += pythonbin + " src/test_typedesc.py > out.txt" - diff --git a/testsuite/python-typedesc/src/test_typedesc.py b/testsuite/python-typedesc/src/test_typedesc.py index c408b7acb9..1c918c85fb 100755 --- a/testsuite/python-typedesc/src/test_typedesc.py +++ b/testsuite/python-typedesc/src/test_typedesc.py @@ -9,7 +9,6 @@ import OpenImageIO as oiio - # Test that every expected enum value of BASETYPE exists def basetype_enum_test(): try: @@ -73,8 +72,9 @@ def vecsemantics_enum_test(): except: print ("Failed VECSEMANTICS") + # print the details of a type t -def breakdown_test(t: oiio.TypeDesc, name="", verbose=True): +def breakdown_test(t, name="", verbose=True): print ("type '%s'" % name) print (" c_str \"" + t.c_str() + "\"") if verbose: @@ -142,6 +142,44 @@ def breakdown_test(t: oiio.TypeDesc, name="", verbose=True): print ("equivalent(vector,float)", oiio.TypeDesc.equivalent(oiio.TypeDesc("vector"), oiio.TypeDesc("float"))) print ("") + # Exercise property mutation and helper methods that are easy to miss in + # binding ports because they are not just plain constructors/accessors. + t_mut = oiio.TypeDesc() + t_mut.basetype = oiio.FLOAT + t_mut.aggregate = oiio.VEC3 + t_mut.vecsemantics = oiio.COLOR + t_mut.arraylen = 2 + breakdown_test (t_mut, "mutated FLOAT, VEC3, COLOR, array of 2") + t_from = oiio.TypeDesc() + t_from.fromstring("point") + breakdown_test (t_from, "fromstring('point')", verbose=False) + t_unarray = oiio.TypeDesc("float[2]") + t_unarray.unarray() + print ("after unarray('float[2]') =", t_unarray) + print ("vector is_vec2,is_vec3,is_vec4 =", + oiio.TypeDesc("vector").is_vec2(oiio.FLOAT), + oiio.TypeDesc("vector").is_vec3(oiio.FLOAT), + oiio.TypeDesc("vector").is_vec4(oiio.FLOAT)) + print ("box2i is_box2,is_box3 =", + oiio.TypeDesc("box2i").is_box2(oiio.INT), + oiio.TypeDesc("box2i").is_box3(oiio.INT)) + print ("all_types_equal([uint8,uint8]) =", + oiio.TypeDesc.all_types_equal([oiio.TypeDesc("uint8"), + oiio.TypeDesc("uint8")])) + print ("all_types_equal([uint8,uint16]) =", + oiio.TypeDesc.all_types_equal([oiio.TypeDesc("uint8"), + oiio.TypeDesc("uint16")])) + print ("repr(TypeFloat) =", repr(oiio.TypeFloat)) + print ("") + + # Exercise implicit conversion paths used by the production pybind11 + # binding: BASETYPE -> TypeDesc and Python str -> TypeDesc. + implicit_enum_spec = oiio.ImageSpec(8, 9, 3, oiio.UINT8) + implicit_str_spec = oiio.ImageSpec(8, 9, 3, "uint8") + print ("implicit enum ImageSpec roi =", implicit_enum_spec.roi) + print ("implicit str ImageSpec roi =", implicit_str_spec.roi) + print ("") + # Test the pre-constructed types breakdown_test (oiio.TypeFloat, "TypeFloat", verbose=False) breakdown_test (oiio.TypeColor, "TypeColor", verbose=False) @@ -179,4 +217,3 @@ def breakdown_test(t: oiio.TypeDesc, name="", verbose=True): print ("Done.") except Exception as detail: print ("Unknown exception:", detail) - diff --git a/testsuite/runtest.py b/testsuite/runtest.py index 052b68434a..680c3cd905 100755 --- a/testsuite/runtest.py +++ b/testsuite/runtest.py @@ -12,6 +12,8 @@ import difflib import filecmp import shutil +import shlex +from typing import Optional from optparse import OptionParser @@ -53,6 +55,12 @@ def make_relpath (path: str, start: str=os.curdir) -> str: p = os.path.relpath (path, start) return p if platform.system() != 'Windows' else p.replace ('\\', '/') + +def shell_quote(arg: str) -> str: + if platform.system() == 'Windows': + return subprocess.list2cmdline([arg]) + return shlex.quote(arg) + # Try to figure out where some key things are. Go by env variables set by # the cmake tests, but if those aren't set, assume somebody is running # this script by hand from inside build/testsuite/TEST and that @@ -82,6 +90,13 @@ def make_relpath (path: str, start: str=os.curdir) -> str: test_source_dir = os.getenv('OIIO_TESTSUITE_SRC', os.path.join(OIIO_TESTSUITE_ROOT, mytest)) +# Python tests listed here also run against the staged nanobind package when +# it exists in the current build tree. +nanobind_python_tests = { + "python-roi", + "python-typedesc", +} + def oiio_app (app: str) -> str: if (platform.system () != 'Windows' or options.devenv_config == ""): cmd = os.path.join(OIIO_BUILD_ROOT, "bin", app) + " " @@ -101,6 +116,7 @@ def oiio_app (app: str) -> str: command = "" outputs = [ "out.txt" ] # default +ref_name_overrides = {} # The image comparison thresholds are tricky to remember. Here's the key: # A test fails if more than `failpercent` of pixel values differ by more @@ -354,16 +370,18 @@ def oiiotool (args: str, silent: bool=False, concat: bool=True, failureok: bool= # the identical name, and if that fails, it will look for alternatives of # the form "basename-*.ext" (or ANY match in the ref directory, if anymatch # is True). -def checkref (name: str, refdirlist: list[str]) -> tuple[bool, str]: +def checkref (name: str, refdirlist: list[str], refname: Optional[str]=None) -> tuple[bool, str]: # Break the output into prefix+extension - (prefix, extension) = os.path.splitext(name) + if refname is None: + refname = name + (prefix, extension) = os.path.splitext(refname) ok = 0 for ref in refdirlist : # We will first compare name to ref/name, and if that fails, we will # compare it to everything else that matches ref/prefix-*.extension. # That allows us to have multiple matching variants for different # platforms, etc. - defaulttest = os.path.join(ref,name) + defaulttest = os.path.join(ref,refname) if anymatch : pattern = "*.*" else : @@ -400,6 +418,9 @@ def runtest (command: str, outputs: list[str], failureok: int=0) -> int : err = 0 # print ("working dir = " + tmpdir) os.chdir (srcdir) + for out in ("out.txt", "out.err.txt", "out-nanobind.txt"): + if os.path.exists(out): + os.remove(out) open ("out.txt", "w").close() # truncate out.txt open ("out.err.txt", "w").close() # truncate out.txt if os.path.isfile("debug.log") : @@ -437,7 +458,8 @@ def runtest (command: str, outputs: list[str], failureok: int=0) -> int : if os.path.exists('crlf.txt') : os.remove('crlf.txt') - (ok, testfile) = checkref (out, refdirlist) + refname = ref_name_overrides.get(out, out) + (ok, testfile) = checkref (out, refdirlist, refname=refname) if ok : if extension in image_extensions : @@ -477,13 +499,61 @@ def runtest (command: str, outputs: list[str], failureok: int=0) -> int : # -# Read the individual run.py file for this test, which will define +# Read the individual run.py file for this test, which will define # command and outputs. # with open(os.path.join(test_source_dir,"run.py")) as f: code = compile(f.read(), "run.py", 'exec') exec (code) +# For tests that have a nanobind port, run the same canonical Python test +# body a second time against the staged nanobind package from the current +# build tree. Keep the output separate so failures still indicate which +# backend mismatched the shared reference output. +nanobind_package = os.path.join( + OIIO_BUILD_ROOT, "lib", "python", "nanobind", "OpenImageIO", "__init__.py" +) +if mytest in nanobind_python_tests and os.path.exists(nanobind_package): + nanobind_test_scripts = sorted(glob.glob(os.path.join(test_source_dir, "src", "*.py"))) + if len(nanobind_test_scripts) != 1: + raise RuntimeError( + "Expected exactly one Python test script under " + + os.path.join(test_source_dir, "src") + + ", found " + + str(len(nanobind_test_scripts)) + ) + nanobind_test_script = make_relpath(nanobind_test_scripts[0], tmpdir) + nanobind_package_root = make_relpath( + os.path.join(OIIO_BUILD_ROOT, "lib", "python", "nanobind"), + tmpdir, + ) + # Re-run the same canonical test script as a standalone program, but + # with the staged nanobind package inserted first on sys.path so + # `import OpenImageIO` resolves to the nanobind backend. + nanobind_code = ( + "import runpy, sys\n" + + "sys.path.insert(0, " + + repr(nanobind_package_root) + + ")\n" + + "runpy.run_path(" + + repr(nanobind_test_script) + + ", run_name='__main__')\n" + ) + command += ( + " ; " + + shell_quote(pythonbin) + + " -c " + + shell_quote(nanobind_code) + + " > out-nanobind.txt" + ) + # Example of final command for `python-roi` would be: + # python src/test_roi.py > out.txt ; \ + # python -c 'import runpy, sys + # sys.path.insert(0, "../../lib/python/nanobind") + # runpy.run_path("src/test_roi.py", run_name="__main__")' > out-nanobind.txt + outputs.append("out-nanobind.txt") + ref_name_overrides["out-nanobind.txt"] = "out.txt" + # Allow a little more slop for slight pixel differences when in DEBUG # mode or when running on remote CI machines. if (os.getenv('CI') or os.getenv('DEBUG')) :