diff --git a/.clang-format b/.clang-format new file mode 100644 index 0000000..485de36 --- /dev/null +++ b/.clang-format @@ -0,0 +1,91 @@ +BasedOnStyle: Mozilla + +AccessModifierOffset: '-4' +AlignAfterOpenBracket: BlockIndent +AlignEscapedNewlines: Left +AllowAllArgumentsOnNextLine: false +AllowAllParametersOfDeclarationOnNextLine: false +AllowShortBlocksOnASingleLine: false +AllowShortCaseLabelsOnASingleLine: false +AllowShortFunctionsOnASingleLine: false +AllowShortIfStatementsOnASingleLine: false +# Forbid one line lambdas because clang-format makes a weird split when +# single instructions lambdas are too long. +AllowShortLambdasOnASingleLine: Empty +AllowShortLoopsOnASingleLine: false +AlwaysBreakAfterDefinitionReturnType: None +AlwaysBreakAfterReturnType: None +AlwaysBreakTemplateDeclarations: Yes +BinPackArguments: false +BinPackParameters: false +BreakBeforeBinaryOperators: NonAssignment +BreakBeforeBraces: Allman +BreakBeforeTernaryOperators: true +BreakConstructorInitializers: BeforeComma +BreakInheritanceList: AfterComma +BreakStringLiterals: false +ColumnLimit: '110' +ConstructorInitializerIndentWidth: '4' +ContinuationIndentWidth: '4' +Cpp11BracedListStyle: true +DerivePointerAlignment: false +DisableFormat: false +EmptyLineAfterAccessModifier: Always +EmptyLineBeforeAccessModifier: Always +ExperimentalAutoDetectBinPacking: true +IncludeBlocks: Regroup +IncludeCategories: +- Regex: <[^.]+> + Priority: 1 +- Regex: + Priority: 3 +- Regex: <.+> + Priority: 2 +- Regex: '"sparrow/.+"' + Priority: 4 +- Regex: '".+"' + Priority: 5 +IndentCaseLabels: true +IndentPPDirectives: AfterHash +IndentWidth: '4' +IndentWrappedFunctionNames: false +InsertBraces: true +InsertTrailingCommas: Wrapped +KeepEmptyLinesAtTheStartOfBlocks: false +LambdaBodyIndentation: Signature +Language: Cpp +MaxEmptyLinesToKeep: '2' +NamespaceIndentation: All +ObjCBlockIndentWidth: '4' +ObjCSpaceAfterProperty: false +ObjCSpaceBeforeProtocolList: false +PackConstructorInitializers: Never +PenaltyBreakAssignment: 100000 +PenaltyBreakBeforeFirstCallParameter: 0 +PenaltyBreakComment: 10 +PenaltyBreakOpenParenthesis: 0 +PenaltyBreakTemplateDeclaration: 0 +PenaltyExcessCharacter: 10 +PenaltyIndentedWhitespace: 0 +PenaltyReturnTypeOnItsOwnLine: 10 +PointerAlignment: Left +QualifierAlignment: Custom # Experimental +QualifierOrder: [inline, static, constexpr, const, volatile, type] +ReflowComments: true +SeparateDefinitionBlocks: Always +SortIncludes: CaseInsensitive +SortUsingDeclarations: true +SpaceAfterCStyleCast: true +SpaceAfterTemplateKeyword: true +SpaceBeforeAssignmentOperators: true +SpaceBeforeParens: ControlStatements +SpaceInEmptyParentheses: false +SpacesBeforeTrailingComments: '2' +SpacesInAngles: false +SpacesInCStyleCastParentheses: false +SpacesInContainerLiterals: false +SpacesInParentheses: false +SpacesInSquareBrackets: false +Standard: c++20 +TabWidth: '4' +UseTab: Never \ No newline at end of file diff --git a/.clang-tidy b/.clang-tidy new file mode 100644 index 0000000..1fa6d87 --- /dev/null +++ b/.clang-tidy @@ -0,0 +1,18 @@ +--- +Checks: 'clang-diagnostic-*,clang-analyzer-*,cppcoreguidelines-*,modernize-*,performance-*,portability-*,-modernize-use-trailing-return-type' +WarningsAsErrors: '' +HeaderFileExtensions: + - '' + - h + - hh + - hpp + - hxx +ImplementationFileExtensions: + - c + - cc + - cpp + - cxx +HeaderFilterRegex: '' +FormatStyle: file +SystemHeaders: false +... diff --git a/.github/workflows/linux.yml b/.github/workflows/linux.yml new file mode 100644 index 0000000..552bdf4 --- /dev/null +++ b/.github/workflows/linux.yml @@ -0,0 +1,90 @@ +name: Linux build and test + +on: + workflow_dispatch: + pull_request: + push: + branches: [main] + +defaults: + run: + shell: bash -l -eo pipefail {0} + +jobs: + linux_build_from_conda_forge: + runs-on: ubuntu-latest + strategy: + matrix: + build_type: [Release, Debug] + build_shared: [ON, OFF] + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Create build environment + uses: mamba-org/setup-micromamba@v2 + with: + environment-file: ./environment-dev.yml + environment-name: build_env + cache-environment: true + + - name: Configure using cmake + run: | + cmake -G Ninja \ + -Bbuild \ + -DCMAKE_BUILD_TYPE=${{ matrix.build_type }} \ + -DCMAKE_INSTALL_PREFIX=$CONDA_PREFIX \ + -DCMAKE_PREFIX_PATH=$CONDA_PREFIX \ + -DSPARROW_PYCAPSULE_BUILD_SHARED=${{ matrix.build_shared }} \ + -DSPARROW_PYCAPSULE_BUILD_TESTS=ON + + - name: Build sparrow-pycapsule + working-directory: build + run: cmake --build . --target sparrow-pycapsule + + - name: Build tests + working-directory: build + run: cmake --build . --target test_sparrow_pycapsule_lib + + - name: Run tests + working-directory: build + run: cmake --build . --target run_tests_with_junit_report + + - name: Install + working-directory: build + run: cmake --install . + + linux_build_fetch_from_source: + runs-on: ubuntu-latest + strategy: + matrix: + build_type: [Release, Debug] + build_shared: [ON, OFF] + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Configure using cmake + run: | + cmake -G Ninja \ + -Bbuild \ + -DCMAKE_BUILD_TYPE=${{ matrix.build_type }} \ + -DSPARROW_PYCAPSULE_BUILD_SHARED=${{ matrix.build_shared }} \ + -DSPARROW_PYCAPSULE_BUILD_TESTS=ON \ + -DFETCH_DEPENDENCIES_WITH_CMAKE=MISSING + + - name: Build sparrow-pycapsule + working-directory: build + run: cmake --build . --target sparrow-pycapsule + + - name: Build tests + working-directory: build + run: cmake --build . --target test_sparrow_pycapsule_lib + + - name: Run tests + working-directory: build + run: cmake --build . --target run_tests_with_junit_report + + - name: Install + working-directory: build + run: sudo cmake --install . diff --git a/.github/workflows/osx.yml b/.github/workflows/osx.yml new file mode 100644 index 0000000..0935925 --- /dev/null +++ b/.github/workflows/osx.yml @@ -0,0 +1,100 @@ +name: OSX build and test + +on: + workflow_dispatch: + pull_request: + push: + branches: [main] + +defaults: + run: + shell: bash -l -eo pipefail {0} + +jobs: + osx_build_from_conda_forge: + runs-on: macos-latest + strategy: + matrix: + build_type: [Release, Debug] + build_shared: [ON, OFF] + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Select XCode version + run: | + sudo xcode-select --switch /Applications/Xcode_16.4.app/Contents/Developer + xcodebuild -version + + - name: Create build environment + uses: mamba-org/setup-micromamba@v2 + with: + environment-file: ./environment-dev.yml + environment-name: build_env + cache-environment: true + + - name: Configure using cmake + run: | + cmake -G Ninja \ + -Bbuild \ + -DCMAKE_BUILD_TYPE=${{ matrix.build_type }} \ + -DCMAKE_INSTALL_PREFIX=$CONDA_PREFIX \ + -DCMAKE_PREFIX_PATH=$CONDA_PREFIX \ + -DSPARROW_PYCAPSULE_BUILD_SHARED=${{ matrix.build_shared }} \ + -DSPARROW_PYCAPSULE_BUILD_TESTS=ON + + - name: Build sparrow-pycapsule + working-directory: build + run: cmake --build . --target sparrow-pycapsule + + - name: Build tests + working-directory: build + run: cmake --build . --target test_sparrow_pycapsule_lib + + - name: Run tests + working-directory: build + run: cmake --build . --target run_tests_with_junit_report + + - name: Install + working-directory: build + run: cmake --install . + + osx_build_fetch_from_source: + runs-on: macos-latest + strategy: + matrix: + build_type: [Release, Debug] + build_shared: [ON, OFF] + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Select XCode version + run: | + sudo xcode-select --switch /Applications/Xcode_16.4.app/Contents/Developer + xcodebuild -version + + - name: Configure using cmake + run: | + cmake -G Ninja \ + -Bbuild \ + -DCMAKE_BUILD_TYPE=${{ matrix.build_type }} \ + -DSPARROW_PYCAPSULE_BUILD_SHARED=${{ matrix.build_shared }} \ + -DSPARROW_PYCAPSULE_BUILD_TESTS=ON \ + -DFETCH_DEPENDENCIES_WITH_CMAKE=MISSING + + - name: Build sparrow-pycapsule + working-directory: build + run: cmake --build . --target sparrow-pycapsule + + - name: Build tests + working-directory: build + run: cmake --build . --target test_sparrow_pycapsule_lib + + - name: Run tests + working-directory: build + run: cmake --build . --target run_tests_with_junit_report + + - name: Install + working-directory: build + run: sudo cmake --install . diff --git a/.github/workflows/windows.yml b/.github/workflows/windows.yml new file mode 100644 index 0000000..a8ce68d --- /dev/null +++ b/.github/workflows/windows.yml @@ -0,0 +1,94 @@ +name: Windows build and test + +on: + workflow_dispatch: + pull_request: + push: + branches: [main] + +defaults: + run: + shell: bash -e -l {0} + +jobs: + windows_build_from_conda_forge: + runs-on: windows-latest + strategy: + matrix: + build_type: [Release, Debug] + build_shared: [ON, OFF] + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Create build environment + uses: mamba-org/setup-micromamba@v2 + with: + environment-file: ./environment-dev.yml + environment-name: build_env + init-shell: bash + cache-environment: true + + - name: Configure using cmake + run: | + if [ "${{ matrix.build_type }}" == "Release" ]; then + GLOB_PREFIX_PATH="$CONDA_PREFIX" + elif [ "${{ matrix.build_type }}" == "Debug" ]; then + GLOB_PREFIX_PATH="$CONDA_PREFIX/Library/debug;$CONDA_PREFIX" + fi + cmake -S ./ -B ./build \ + -DCMAKE_INSTALL_PREFIX=$CONDA_PREFIX \ + -DCMAKE_PREFIX_PATH=$GLOB_PREFIX_PATH \ + -DSPARROW_PYCAPSULE_BUILD_SHARED=${{ matrix.build_shared }} \ + -DSPARROW_PYCAPSULE_BUILD_TESTS=ON + + - name: Build sparrow-pycapsule + working-directory: build + run: cmake --build . --config ${{ matrix.build_type }} --target sparrow-pycapsule + + - name: Build tests + working-directory: build + run: cmake --build . --config ${{ matrix.build_type }} --target test_sparrow_pycapsule_lib + + - name: Run tests + working-directory: build + run: | + cmake --build . --config ${{ matrix.build_type }} --target run_tests_with_junit_report + + - name: Install + working-directory: build + run: cmake --install . --config ${{ matrix.build_type }} + + windows_build_fetch_from_source: + runs-on: windows-latest + strategy: + matrix: + build_type: [Release, Debug] + build_shared: [ON, OFF] + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Configure using cmake + run: | + cmake -S ./ -B ./build \ + -DSPARROW_PYCAPSULE_BUILD_SHARED=${{ matrix.build_shared }} \ + -DSPARROW_PYCAPSULE_BUILD_TESTS=ON \ + -DFETCH_DEPENDENCIES_WITH_CMAKE=MISSING + + - name: Build sparrow-pycapsule + working-directory: build + run: cmake --build . --config ${{ matrix.build_type }} --target sparrow-pycapsule + + - name: Build tests + working-directory: build + run: cmake --build . --config ${{ matrix.build_type }} --target test_sparrow_pycapsule_lib + + - name: Run tests + working-directory: build + run: cmake --build . --config ${{ matrix.build_type }} --target run_tests_with_junit_report + + - name: Install + working-directory: build + run: cmake --install . --config ${{ matrix.build_type }} + \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..046c078 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/build +/.vscode diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..4478281 --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,294 @@ +cmake_minimum_required(VERSION 3.28) + +# This is better specified per target, but cmake keeps ignoring these language version +# specification when building this project by itself, in particular the gnu extensions, +# so here we go. +# This will affects all following targets being defined. +set(CMAKE_CXX_EXTENSIONS OFF) +cmake_policy(SET CMP0091 NEW) + +# MSVC debug information format flags are selected via +# CMAKE_MSVC_DEBUG_INFORMATION_FORMAT, instead of +# embedding flags in e.g. CMAKE_CXX_FLAGS_RELEASE. +# New in CMake 3.25. +# +# Supports debug info with SCCache +# (https://github.com/mozilla/sccache?tab=readme-ov-file#usage) +# avoiding β€œfatal error C1041: cannot open program database; if +# multiple CL.EXE write to the same .PDB file, please use /FS" +set(CMAKE_MSVC_DEBUG_INFORMATION_FORMAT Embedded) +cmake_policy(SET CMP0141 NEW) + +project(sparrow-pycapsule CXX) + +if(NOT CMAKE_CXX_STANDARD) + set(CMAKE_CXX_STANDARD 20) +endif() + +message(STATUS "πŸ”§ C++ standard: ${CMAKE_CXX_STANDARD}") +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_SCAN_FOR_MODULES OFF) # We don't use modules +include(CMakeDependentOption) + +list(PREPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_LIST_DIR}/cmake") +message(DEBUG "CMake module path: ${CMAKE_MODULE_PATH}") + +include(external_dependencies) + +set(SPARROW_PYCAPSULE_INCLUDE_DIR ${CMAKE_CURRENT_SOURCE_DIR}/include) +set(SPARROW_PYCAPSULE_SOURCE_DIR ${CMAKE_CURRENT_SOURCE_DIR}/src) + +if(CMAKE_CXX_COMPILER_LAUNCHER) + message(STATUS "Using C++ compiler launcher: ${CMAKE_CXX_COMPILER_LAUNCHER}") +endif() + +# Versionning +# =========== + +set(sparrow_pycapsule_version_path "${SPARROW_PYCAPSULE_INCLUDE_DIR}/sparrow-pycapsule/config/sparrow_pycapsule_version.hpp") +file(STRINGS "${sparrow_pycapsule_version_path}" sparrow_pycapsule_version_defines + REGEX "constexpr int SPARROW_PYCAPSULE_VERSION_(MAJOR|MINOR|PATCH)") + +foreach(ver ${sparrow_pycapsule_version_defines}) + if(ver MATCHES "constexpr int SPARROW_PYCAPSULE_VERSION_(MAJOR|MINOR|PATCH) = ([0-9]+);$") + set(PROJECT_VERSION_${CMAKE_MATCH_1} "${CMAKE_MATCH_2}" CACHE INTERNAL "") + endif() +endforeach() + +set(CMAKE_PROJECT_VERSION + ${PROJECT_VERSION_MAJOR}.${PROJECT_VERSION_MINOR}.${PROJECT_VERSION_PATCH}) + +message(STATUS "Building sparrow v${CMAKE_PROJECT_VERSION}") + +# Binary version +# See the following URL for explanations about the binary versionning +# https://www.gnu.org/software/libtool/manual/html_node/Updating-version-info.html#Updating-version-info +file(STRINGS "${sparrow_pycapsule_version_path}" sparrow_pycapsule_version_defines + REGEX "constexpr int SPARROW_PYCAPSULE_BINARY_(CURRENT|REVISION|AGE)") + +foreach(ver ${sparrow_pycapsule_version_defines}) + if(ver MATCHES "constexpr int SPARROW_PYCAPSULE_BINARY_(CURRENT|REVISION|AGE) = ([0-9]+);$") + set(SPARROW_PYCAPSULE_BINARY_${CMAKE_MATCH_1} "${CMAKE_MATCH_2}" CACHE INTERNAL "") + endif() +endforeach() + +set(SPARROW_PYCAPSULE_BINARY_VERSION + ${SPARROW_PYCAPSULE_BINARY_CURRENT}.${SPARROW_PYCAPSULE_BINARY_REVISION}.${SPARROW_PYCAPSULE_BINARY_AGE}) + +message(STATUS "sparrow pycapsule binary version: v${SPARROW_PYCAPSULE_BINARY_VERSION}") + +# Build options +# ============= + +option(SPARROW_PYCAPSULE_BUILD_TESTS "Build sparrow test suite" OFF) +message(STATUS "πŸ”§ Build tests: ${SPARROW_PYCAPSULE_BUILD_TESTS}") +option(BUILD_DOCS "Build sparrow documentation" OFF) +message(STATUS "πŸ”§ Build docs: ${BUILD_DOCS}") + +set(CMAKE_DEBUG_POSTFIX "d") + +option(SPARROW_CONTRACTS_THROW_ON_FAILURE "Throw exceptions instead of aborting on contract failures" OFF) +message(STATUS "πŸ”§ Contracts throw on failure: ${SPARROW_CONTRACTS_THROW_ON_FAILURE}") + +option(ENABLE_COVERAGE "Enable test coverage" OFF) +message(STATUS "πŸ”§ Enable coverage: ${ENABLE_COVERAGE}") + +if(ENABLE_COVERAGE) + include(code_coverage) +endif() + +include(CheckCXXSymbolExists) + +if(cxx_std_20 IN_LIST CMAKE_CXX_COMPILE_FEATURES) + set(header version) +else() + set(header ciso646) +endif() + +check_cxx_symbol_exists(_LIBCPP_VERSION ${header} LIBCPP) + +if(LIBCPP) + message(STATUS "Using libc++") + + # Allow the use of not visible yet availabile features, such + # as some formatter for new types. + add_compile_definitions(_LIBCPP_DISABLE_AVAILABILITY) +endif() + +# Linter options +# ============= +option(ACTIVATE_LINTER "Create targets to run clang-format" OFF) +message(STATUS "πŸ”§ Activate linter: ${ACTIVATE_LINTER}") +cmake_dependent_option(ACTIVATE_LINTER_DURING_COMPILATION "Run linter during the compilation" ON "ACTIVATE_LINTER" OFF) + +if(ACTIVATE_LINTER) + include(clang-format) + include(clang-tidy) +endif() + +# Sanitizers +# ========== +# include(sanitizers) // TODO: add sanitizers support + +# Dependencies +# ============ + +# include(external_dependencies) // TODO: add external dependencies support + +set(SPARROW_PYCAPSULE_COMPILE_DEFINITIONS "" CACHE STRING "List of public compile definitions of the sparrow target") + +# Build +# ===== +set(BINARY_BUILD_DIR "${CMAKE_BINARY_DIR}/bin/${CMAKE_BUILD_TYPE}") + +set(CMAKE_RUNTIME_OUTPUT_DIRECTORY_DEBUG "${BINARY_BUILD_DIR}") +set(CMAKE_RUNTIME_OUTPUT_DIRECTORY_RELEASE "${BINARY_BUILD_DIR}") +set(CMAKE_LIBRARY_OUTPUT_DIRECTORY_DEBUG "${BINARY_BUILD_DIR}") +set(CMAKE_LIBRARY_OUTPUT_DIRECTORY_RELEASE "${BINARY_BUILD_DIR}") +set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY_DEBUG "${BINARY_BUILD_DIR}") +set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY_RELEASE "${BINARY_BUILD_DIR}") + +set(SPARROW_PYCAPSULE_HEADERS + ${SPARROW_PYCAPSULE_INCLUDE_DIR}/sparrow-pycapsule/config/sparrow_pycapsule_version.hpp + ${SPARROW_PYCAPSULE_INCLUDE_DIR}/sparrow-pycapsule/pycapsule.hpp +) + +set(SPARROW_PYCAPSULE_SOURCES + src/pycapsule.cpp +) + +option(SPARROW_PYCAPSULE_BUILD_SHARED "Build sparrow pycapsule as a shared library" ON) + +if(SPARROW_PYCAPSULE_BUILD_SHARED) + message(STATUS "πŸ”§ Build shared library") + set(SPARROW_PYCAPSULE_LIBRARY_TYPE SHARED) +else() + message(STATUS "πŸ”§ Build static library") + set(SPARROW_PYCAPSULE_LIBRARY_TYPE STATIC) + list(APPEND SPARROW_PYCAPSULE_COMPILE_DEFINITIONS SPARROW_STATIC_LIB) +endif() + +add_library(sparrow-pycapsule ${SPARROW_PYCAPSULE_LIBRARY_TYPE} ${SPARROW_PYCAPSULE_HEADERS} ${SPARROW_PYCAPSULE_SOURCES}) + +target_link_libraries(sparrow-pycapsule + PUBLIC + Python::Python + sparrow::sparrow) + +target_compile_definitions(sparrow-pycapsule PUBLIC ${SPARROW_PYCAPSULE_COMPILE_DEFINITIONS}) + +set_property(TARGET sparrow-pycapsule PROPERTY POSITION_INDEPENDENT_CODE ON) + +if(UNIX) + # CMake does not compute the version number of so files as libtool + # does on Linux. Strictly speaking, we should exclude FreeBSD and + # Apple from this, but that would require having different version + # numbers depending on the platform. We prefer to follow the + # libtool pattern everywhere. + math(EXPR SPARROW_PYCAPSULE_BINARY_COMPATIBLE "${SPARROW_PYCAPSULE_BINARY_CURRENT} - ${SPARROW_PYCAPSULE_BINARY_AGE}") + set_target_properties( + sparrow-pycapsule + PROPERTIES + VERSION "${SPARROW_PYCAPSULE_BINARY_COMPATIBLE}.${SPARROW_PYCAPSULE_BINARY_REVISION}.${SPARROW_PYCAPSULE_BINARY_AGE}" + SOVERSION ${SPARROW_PYCAPSULE_BINARY_COMPATIBLE} + ) + target_compile_options(sparrow-pycapsule PRIVATE "-fvisibility=hidden") +else() + set_target_properties( + sparrow-pycapsule + PROPERTIES + VERSION ${SPARROW_PYCAPSULE_BINARY_VERSION} + SOVERSION ${SPARROW_PYCAPSULE_BINARY_CURRENT} + ) + target_compile_definitions(sparrow-pycapsule PRIVATE SPARROW_PYCAPSULE_EXPORTS) +endif() + +target_include_directories(sparrow-pycapsule PUBLIC + $ + $) + +# We do not use non-standard C++ +set_target_properties(sparrow-pycapsule PROPERTIES CMAKE_CXX_EXTENSIONS OFF) +target_compile_features(sparrow-pycapsule PUBLIC cxx_std_20) + +if(ENABLE_COVERAGE) + enable_coverage(sparrow-pycapsule) +endif() + +if(CMAKE_CXX_COMPILER_ID STREQUAL "GNU" AND WIN32) + target_compile_options(sparrow-pycapsule + PUBLIC + -Wa,-mbig-obj) +endif() + +if(CMAKE_CXX_COMPILER_ID STREQUAL "Clang") + if (EMSCRIPTEN) + target_compile_options(sparrow-pycapsule + PRIVATE + -fPIC) + else () + target_compile_options(sparrow-pycapsule + PUBLIC + -Wno-c99-extensions) + endif() +elseif(CMAKE_CXX_COMPILER_ID MATCHES "MSVC") + target_compile_options(sparrow-pycapsule + PUBLIC + /bigobj + ) +endif() + +# Docs +# ==== +if(BUILD_DOCS) + add_subdirectory(docs) +endif() + +# Tests +# ===== +if(SPARROW_PYCAPSULE_BUILD_TESTS) + add_subdirectory(test) +endif() + +# Installation +# ============ +include(GNUInstallDirs) +include(CMakePackageConfigHelpers) + +# Install target +set(SPARROW_PYCAPSULE_EXPORTED_TARGETS sparrow-pycapsule) + +install(TARGETS ${SPARROW_PYCAPSULE_EXPORTED_TARGETS} + EXPORT ${PROJECT_NAME}-targets) + +# Makes the project importable from the build directory +export(EXPORT ${PROJECT_NAME}-targets + FILE "${CMAKE_CURRENT_BINARY_DIR}/${PROJECT_NAME}Targets.cmake" + NAMESPACE sparrow::) + +# Install headers +install(DIRECTORY ${SPARROW_PYCAPSULE_INCLUDE_DIR}/sparrow-pycapsule + DESTINATION ${CMAKE_INSTALL_INCLUDEDIR} + PATTERN ".clang-*" EXCLUDE + PATTERN "README.md" EXCLUDE) +# Install CMake configuration files +set(SPARROW_CMAKECONFIG_INSTALL_DIR "${CMAKE_INSTALL_DATADIR}/cmake/${PROJECT_NAME}" CACHE + STRING "install path for sparrowConfig.cmake") + +configure_package_config_file(${PROJECT_NAME}Config.cmake.in + "${CMAKE_CURRENT_BINARY_DIR}/${PROJECT_NAME}Config.cmake" + INSTALL_DESTINATION ${SPARROW_CMAKECONFIG_INSTALL_DIR}) + +write_basic_package_version_file(${CMAKE_CURRENT_BINARY_DIR}/${PROJECT_NAME}ConfigVersion.cmake + VERSION ${CMAKE_PROJECT_VERSION} + COMPATIBILITY AnyNewerVersion) + +install(FILES + ${CMAKE_CURRENT_BINARY_DIR}/${PROJECT_NAME}Config.cmake + ${CMAKE_CURRENT_BINARY_DIR}/${PROJECT_NAME}ConfigVersion.cmake + DESTINATION ${SPARROW_CMAKECONFIG_INSTALL_DIR}) + +install(EXPORT ${PROJECT_NAME}-targets + FILE ${PROJECT_NAME}Targets.cmake + NAMESPACE sparrow:: + DESTINATION ${SPARROW_CMAKECONFIG_INSTALL_DIR}) \ No newline at end of file diff --git a/cmake/clang-format.cmake b/cmake/clang-format.cmake new file mode 100644 index 0000000..483f735 --- /dev/null +++ b/cmake/clang-format.cmake @@ -0,0 +1,84 @@ +set(CLANG-FORMAT_MINIMUM_MAJOR_VERSION 18) + +function(get_clang_format_version clang_format_path) + set(CLANG_FORMAT_VERSION_OUTPUT "") + execute_process( + COMMAND ${clang_format_path} --version + OUTPUT_VARIABLE CLANG_FORMAT_VERSION_OUTPUT + ) + string(REGEX MATCH "([0-9]+)\\.([0-9]+)\\.([0-9]+)" CLANG_FORMAT_VERSION_OUTPUT ${CLANG_FORMAT_VERSION_OUTPUT}) + set(CLANG_FORMAT_MAJOR_VERSION ${CMAKE_MATCH_1} PARENT_SCOPE) + set(CLANG_FORMAT_MINOR_VERSION ${CMAKE_MATCH_2} PARENT_SCOPE) + set(CLANG_FORMAT_PATCH_VERSION ${CMAKE_MATCH_3} PARENT_SCOPE) +endfunction() + +function(check_clang-format_version validator_result_var item) + set(${validator_result_var} FALSE PARENT_SCOPE) + get_clang_format_version(${item}) + if (CLANG_FORMAT_MAJOR_VERSION LESS CLANG-FORMAT_MINIMUM_MAJOR_VERSION) + message(DEBUG "clang-format found at ${item} | version: ${CLANG_FORMAT_MAJOR_VERSION}.${CLANG_FORMAT_MINOR_VERSION}.${CLANG_FORMAT_PATCH_VERSION}") + message(DEBUG "but version is lower than ${CLANG-FORMAT_MINIMUM_MAJOR_VERSION}") + set(${validator_result_var} FALSE PARENT_SCOPE) + else() + set(${validator_result_var} TRUE PARENT_SCOPE) + endif() +endfunction() + +function(print_clang_format_install_instructions) + message(STATUS "πŸ› οΈ Please install clang-format to enable code formatting") + message(STATUS "Can be installed via conda-forge: https://prefix.dev/channels/conda-forge/packages/clang-format") + if(UNIX) + if(APPLE) + message(STATUS "🍎 On MacOS, you can install clang-format with:") + message(STATUS "\t> brew install clang-format") + else() + message(STATUS "🐧 On Ubuntu, you can install clang-format with:") + message(STATUS "\t> sudo apt-get install clang-format") + endif() + elseif(WIN32) + message(STATUS "πŸͺŸ On Windows, you can install clang-format with:") + message(STATUS "\t> winget llvm") + endif() +endfunction() + +find_program(CLANG_FORMAT clang-format + VALIDATOR check_clang-format_version) + +if(NOT CLANG_FORMAT) + message(WARNING "❗ clang-format not found") + + print_clang_format_install_instructions() +else() + get_clang_format_version(${CLANG_FORMAT}) + message(STATUS "βœ… clang-format (version: ${CLANG_FORMAT_MAJOR_VERSION}.${CLANG_FORMAT_MINOR_VERSION}.${CLANG_FORMAT_PATCH_VERSION}) found at ${CLANG_FORMAT}") + + # list all files to format + set( + FORMAT_PATTERNS + include/*.hpp + test/*.cpp + test/*.hpp + CACHE STRING + "; separated patterns relative to the project source dir to format" + ) + + set(ALL_FILES_TO_FORMAT "") + foreach(PATTERN ${FORMAT_PATTERNS}) + file(GLOB_RECURSE FILES_TO_FORMAT ${CMAKE_SOURCE_DIR}/${PATTERN}) + list(APPEND ALL_FILES_TO_FORMAT ${FILES_TO_FORMAT}) + endforeach() + + add_custom_target( + clang-format + COMMAND ${CLANG_FORMAT} -i -style=file ${ALL_FILES_TO_FORMAT} + WORKING_DIRECTORY ${CMAKE_SOURCE_DIR} + COMMENT "Running clang-format on all files" + ) + + add_custom_target( + clang-format_dry_run + COMMAND ${CLANG_FORMAT} --dry-run -style=file ${ALL_FILES_TO_FORMAT} + WORKING_DIRECTORY ${CMAKE_SOURCE_DIR} + COMMENT "Running dry clang-format on all files" + ) +endif() diff --git a/cmake/clang-tidy.cmake b/cmake/clang-tidy.cmake new file mode 100644 index 0000000..ec276b7 --- /dev/null +++ b/cmake/clang-tidy.cmake @@ -0,0 +1,118 @@ +if(CMAKE_GENERATOR MATCHES "Ninja|Unix Makefiles") + message(STATUS "πŸ”§ CMAKE_EXPORT_COMPILE_COMMANDS will be used to enable clang-tidy") + set(CMAKE_EXPORT_COMPILE_COMMANDS ON) +else() + message(WARNING "🚧 CMAKE_EXPORT_COMPILE_COMMANDS can't be used because the CMAKE_GENERATOR is ${CMAKE_GENERATOR}. +You have to use Ninja or Unix Makefiles. +Without CMAKE_EXPORT_COMPILE_COMMANDS, clang-tidy will not work. +CMAKE_EXPORT_COMPILE_COMMANDS is used to generate a JSON file that contains all the compiler commands used to build the project. +This file is used by clang-tidy to know how to compile the project.") +endif() + +set(CLANG-TIDY_MINIMUM_MAJOR_VERSION 18) + +function(get_clang_tidy_version clang_tidy_path) + set(CLANG_TIDY_VERSION_OUTPUT "") + execute_process( + COMMAND ${clang_tidy_path} --version + OUTPUT_VARIABLE CLANG_TIDY_VERSION_OUTPUT + ) + string(REGEX MATCH "([0-9]+)\\.([0-9]+)\\.([0-9]+)" CLANG_TIDY_VERSION_OUTPUT ${CLANG_TIDY_VERSION_OUTPUT}) + set(CLANG_TIDY_MAJOR_VERSION ${CMAKE_MATCH_1} PARENT_SCOPE) + set(CLANG_TIDY_MINOR_VERSION ${CMAKE_MATCH_2} PARENT_SCOPE) + set(CLANG_TIDY_PATCH_VERSION ${CMAKE_MATCH_3} PARENT_SCOPE) +endfunction() + +function(check_clang-tidy_version validator_result_var item) + set(${validator_result_var} FALSE PARENT_SCOPE) + get_clang_tidy_version(${item}) + if (CLANG_TIDY_MAJOR_VERSION LESS CLANG-TIDY_MINIMUM_MAJOR_VERSION) + message(DEBUG "clang-tidy (version: ${CLANG_TIDY_MAJOR_VERSION}.${CLANG_TIDY_MINOR_VERSION}.${CLANG_TIDY_PATCH_VERSION}) found at ${item}") + message(DEBUG "but clang-tidy with version >= ${CLANG-TIDY_MINIMUM_MAJOR_VERSION} must be used.") + set(${validator_result_var} FALSE PARENT_SCOPE) + else() + set(${validator_result_var} TRUE PARENT_SCOPE) + endif() +endfunction() + +function(print_clang_tidy_install_instructions) + message(STATUS "πŸ› οΈ Please install clang-tidy to enable code formatting") + if(UNIX) + if(APPLE) + message(STATUS "🍎 On MacOS, you can install clang-tidy with:") + message(STATUS "\t> brew install clang-tidy") + else() + message(STATUS "🐧 On Ubuntu, you can install clang-tidy with:") + message(STATUS "\t> sudo apt-get install clang-tidy") + endif() + elseif(WIN32) + message(STATUS "πŸͺŸ On Windows, you can install clang-tidy with:") + message(STATUS "\t> winget llvm") + endif() +endfunction() + +find_program(CLANG_TIDY clang-tidy + VALIDATOR check_clang-tidy_version) + +if(NOT CLANG_TIDY) + message(WARNING "❗clang-tidy with version >= ${CLANG-TIDY_MINIMUM_MAJOR_VERSION} not found") + + print_clang_tidy_install_instructions() +else() + get_clang_tidy_version(${CLANG_TIDY}) + message(STATUS "βœ… clang-tidy (version: ${CLANG_TIDY_MAJOR_VERSION}.${CLANG_TIDY_MINOR_VERSION}.${CLANG_TIDY_PATCH_VERSION}) found at ${CLANG_TIDY}") + + if(ACTIVATE_LINTER_DURING_COMPILATION) + message(STATUS "πŸ”§ clang-tidy will be activated during compilation") + set(CMAKE_CXX_CLANG_TIDY ${CLANG_TIDY}) + else() + message(STATUS "πŸ”§ clang-tidy will not be activated during compilation") + set(CMAKE_CXX_CLANG_TIDY "") + endif() + + find_package (Python COMPONENTS Interpreter) + if(Python_Interpreter_FOUND) + message(DEBUG "Python found at ${Python_EXECUTABLE}") + get_filename_component(CLANG_TIDY_FOLDER ${CLANG_TIDY} DIRECTORY) + find_file(CLANG_TIDY_PYTHON_SCRIPT run-clang-tidy PATHS ${CLANG_TIDY_FOLDER} NO_DEFAULT_PATH) + if(CLANG_TIDY_PYTHON_SCRIPT) + message(DEBUG "run-clang-tidy.py found at ${CLANG_TIDY_PYTHON_SCRIPT}") + endif() + set(CLANG_TIDY_COMMAND ${Python_EXECUTABLE} ${CLANG_TIDY_PYTHON_SCRIPT}) + else() + set(CLANG_TIDY_COMMAND ${CLANG_TIDY}) + endif() + + set(CLANG_TIDY_COMMON_ARGUMENTS + $<$>:->-use-color + -p ${CMAKE_BINARY_DIR}) + + set( + PATTERNS + include/*.hpp + test/*.cpp + test/*.hpp + CACHE STRING + "; separated patterns relative to the project source dir to analyse" + ) + + set(ALL_FILES_TO_FORMAT "") + foreach(PATTERN ${PATTERNS}) + file(GLOB_RECURSE FILES_TO_ANALYZE ${CMAKE_SOURCE_DIR}/${PATTERN}) + list(APPEND ALL_FILES_TO_ANALYZE ${FILES_TO_ANALYZE}) + endforeach() + + add_custom_target( + clang-tidy + COMMAND ${CLANG_TIDY_COMMAND} $<$>:->-fix ${CLANG_TIDY_COMMON_ARGUMENTS} ${ALL_FILES_TO_ANALYZE} + WORKING_DIRECTORY ${CMAKE_SOURCE_DIR} + COMMENT "Running clang-tidy on all files" + ) + + add_custom_target( + clang-tidy_dry_run + COMMAND ${CLANG_TIDY_COMMAND} ${CLANG_TIDY_COMMON_ARGUMENTS} + WORKING_DIRECTORY ${CMAKE_SOURCE_DIR} + COMMENT "Running dry clang-tidy on all files" + ) +endif() diff --git a/cmake/compile_options.cmake b/cmake/compile_options.cmake new file mode 100644 index 0000000..f170cfa --- /dev/null +++ b/cmake/compile_options.cmake @@ -0,0 +1,83 @@ + +if(CMAKE_CXX_COMPILER_ID STREQUAL "MSVC") + set(compile_options + /bigobj + /permissive- + /WX # treat warnings as errors + /W4 # Baseline reasonable warnings + /we4242 # 'identifier': conversion from 'type1' to 'type1', possible loss of data + /we4244 # conversion from 'type1' to 'type_2', possible loss of data + /we4254 # 'operator': conversion from 'type1:field_bits' to 'type2:field_bits', possible loss of data + /we4263 # 'function': member function does not override any base class virtual member function + /we4265 # 'classname': class has virtual functions, but destructor is not virtual instances of this class may not be destructed correctly + /we4287 # 'operator': unsigned/negative constant mismatch + /we4289 # nonstandard extension used: 'variable': loop control variable declared in the for-loop is used outside the for-loop scope + /we4296 # 'operator': expression is always 'boolean_value' + /we4311 # 'variable': pointer truncation from 'type1' to 'type2' + /we4545 # expression before comma evaluates to a function which is missing an argument list + /we4546 # function call before comma missing argument list + /we4547 # 'operator': operator before comma has no effect; expected operator with side-effect + /we4549 # 'operator': operator before comma has no effect; did you intend 'operator'? + /we4555 # expression has no effect; expected expression with side- effect + /we4619 # pragma warning: there is no warning number 'number' + /we4640 # Enable warning on thread un-safe static member initialization + /we4826 # Conversion from 'type1' to 'type_2' is sign-extended. This may cause unexpected runtime behavior. + /we4905 # wide string literal cast to 'LPSTR' + /we4906 # string literal cast to 'LPWSTR' + /we4928 # illegal copy-initialization; more than one user-defined conversion has been implicitly applied + /we5038 # data member 'member1' will be initialized after data member 'member2' + /Zc:__cplusplus + PARENT_SCOPE) +elseif(CMAKE_CXX_COMPILER_ID STREQUAL "GNU" OR CMAKE_CXX_COMPILER_ID STREQUAL "Clang") + set(compile_options + -Wall # reasonable and standard + -Wcast-align # warn for potential performance problem casts + -Wconversion # warn on type conversions that may lose data + -Wdouble-promotion # warn if float is implicitly promoted to double + -Werror # treat warnings as errors + -Wextra + -Wformat=2 # warn on security issues around functions that format output (i.e., printf) + -Wimplicit-fallthrough # Warns when case statements fall-through. (Included with -Wextra in GCC, not in clang) + -Wmisleading-indentation # warn if indentation implies blocks where blocks do not exist + -Wnon-virtual-dtor # warn the user if a class with virtual functions has a non-virtual destructor. This helps catch hard to track down memory errors + -Wnull-dereference # warn if a null dereference is detected + -Wold-style-cast # warn for c-style casts + -Woverloaded-virtual # warn if you overload (not override) a virtual function + -Wshadow # warn the user if a variable declaration shadows one from a parent context + -Wsign-conversion # warn on sign conversions + -Wunused # warn on anything being unused + $<$:-Wno-c++98-compat> # do not warn on use of non-C++98 standard + $<$:-Wno-c++98-compat-pedantic> + $<$:-Wno-documentation> + $<$:-Wno-extra-semi-stmt> + $<$:-Wno-c++20-compat> + $<$:-Wno-pre-c++20-compat-pedantic> + $<$:-Wno-reserved-identifier> + $<$:-Wno-undef> + $<$:-Wno-switch-default> + $<$:-Wno-switch-enum> + $<$:-Wno-missing-prototypes> + $<$:-Wno-unused-template> + $<$:-Wno-unsafe-buffer-usage> + $<$:-Wno-documentation-unknown-command> + $<$:-Wno-float-equal> + $<$:-Wno-exit-time-destructors> + $<$:-Wno-global-constructors> + $<$:-Wno-newline-eof> + $<$:-Wno-ctad-maybe-unsupported> + $<$:-Wno-maybe-uninitialized> + $<$:-Wno-array-bounds> + $<$:-Wno-stringop-overread> + $<$:-Wduplicated-branches> # warn if if / else branches have duplicated code + $<$:-Wduplicated-cond> # warn if if / else chain has duplicated conditions + $<$:-Wlogical-op> # warn about logical operations being used where bitwise were probably wanted + $<$:-Wno-subobject-linkage> # suppress warnings about subobject linkage + ) + if (NOT "${CMAKE_CXX_SIMULATE_ID}" STREQUAL "MSVC") + set(compile_options ${compile_options} -ftemplate-backtrace-limit=0 -pedantic) + endif() + + if (CMAKE_CXX_COMPILER_ID STREQUAL "GNU" AND CMAKE_CXX_COMPILER_VERSION VERSION_LESS 11.3) + set(compile_options ${compile_optoins} PRIVATE "-Wno-error=shift-negative-value") + endif() +endif() diff --git a/cmake/external_dependencies.cmake b/cmake/external_dependencies.cmake new file mode 100644 index 0000000..d71d82a --- /dev/null +++ b/cmake/external_dependencies.cmake @@ -0,0 +1,67 @@ +include(FetchContent) + +OPTION(FETCH_DEPENDENCIES_WITH_CMAKE "Fetch dependencies with CMake: Can be OFF, ON, or MISSING. If the latter, CMake will download only dependencies which are not previously found." OFF) +MESSAGE(STATUS "πŸ”§ FETCH_DEPENDENCIES_WITH_CMAKE: ${FETCH_DEPENDENCIES_WITH_CMAKE}") + +if(FETCH_DEPENDENCIES_WITH_CMAKE STREQUAL "OFF") + set(FIND_PACKAGE_OPTIONS REQUIRED) +else() + set(FIND_PACKAGE_OPTIONS QUIET) +endif() + +function(find_package_or_fetch) + set(options) + set(oneValueArgs CONAN_PKG_NAME PACKAGE_NAME GIT_REPOSITORY TAG) + set(multiValueArgs) + cmake_parse_arguments(PARSE_ARGV 0 arg + "${options}" "${oneValueArgs}" "${multiValueArgs}" + ) + + set(actual_pkg_name ${arg_PACKAGE_NAME}) + if(arg_CONAN_PKG_NAME) + set(actual_pkg_name ${arg_CONAN_PKG_NAME}) + endif() + + if(NOT FETCH_DEPENDENCIES_WITH_CMAKE STREQUAL "ON") + find_package(${actual_pkg_name} ${FIND_PACKAGE_OPTIONS}) + endif() + + if(FETCH_DEPENDENCIES_WITH_CMAKE STREQUAL "ON" OR FETCH_DEPENDENCIES_WITH_CMAKE STREQUAL "MISSING") + if(NOT ${actual_pkg_name}_FOUND) + message(STATUS "πŸ“¦ Fetching ${arg_PACKAGE_NAME}") + FetchContent_Declare( + ${arg_PACKAGE_NAME} + GIT_SHALLOW TRUE + GIT_REPOSITORY ${arg_GIT_REPOSITORY} + GIT_TAG ${arg_TAG} + GIT_PROGRESS TRUE + SYSTEM + EXCLUDE_FROM_ALL) + FetchContent_MakeAvailable(${arg_PACKAGE_NAME}) + message(STATUS "\tβœ… Fetched ${arg_PACKAGE_NAME}") + else() + message(STATUS "πŸ“¦ ${actual_pkg_name} found here: ${${actual_pkg_name}_DIR}") + endif() + endif() +endfunction() + +set(SPARROW_BUILD_SHARED ${SPARROW_PYCAPSULE_BUILD_SHARED}) +find_package_or_fetch( + PACKAGE_NAME sparrow + GIT_REPOSITORY https://github.com/man-group/sparrow.git + TAG 1.3.0 +) + +if(NOT TARGET sparrow::sparrow) + add_library(sparrow::sparrow ALIAS sparrow) +endif() + +if(SPARROW_PYCAPSULE_BUILD_TESTS) + find_package_or_fetch( + PACKAGE_NAME doctest + GIT_REPOSITORY https://github.com/doctest/doctest.git + TAG v2.4.12 + ) +endif() + +find_package(Python REQUIRED COMPONENTS Development) diff --git a/environment-dev.yml b/environment-dev.yml new file mode 100644 index 0000000..74a5f2b --- /dev/null +++ b/environment-dev.yml @@ -0,0 +1,15 @@ +name: sparrow-pycapsule +channels: + - conda-forge +dependencies: + # Build + - cmake + - ninja + # Dependencies + - sparrow + - python + # Tests + - doctest + # Documentation + - doxygen + - graphviz diff --git a/include/sparrow-pycapsule/config/config.hpp b/include/sparrow-pycapsule/config/config.hpp new file mode 100644 index 0000000..9625a3d --- /dev/null +++ b/include/sparrow-pycapsule/config/config.hpp @@ -0,0 +1,13 @@ +#pragma once + +#if defined(_WIN32) +# if defined(SPARROW_PYCAPSULE_STATIC_LIB) +# define SPARROW_PYCAPSULE_API +# elif defined(SPARROW_PYCAPSULE_EXPORTS) +# define SPARROW_PYCAPSULE_API __declspec(dllexport) +# else +# define SPARROW_PYCAPSULE_API __declspec(dllimport) +# endif +#else +# define SPARROW_PYCAPSULE_API __attribute__((visibility("default"))) +#endif diff --git a/include/sparrow-pycapsule/config/sparrow_pycapsule_version.hpp b/include/sparrow-pycapsule/config/sparrow_pycapsule_version.hpp new file mode 100644 index 0000000..dafabe4 --- /dev/null +++ b/include/sparrow-pycapsule/config/sparrow_pycapsule_version.hpp @@ -0,0 +1,17 @@ +#pragma once + +namespace sparrow +{ + constexpr int SPARROW_PYCAPSULE_VERSION_MAJOR = 0; + constexpr int SPARROW_PYCAPSULE_VERSION_MINOR = 1; + constexpr int SPARROW_PYCAPSULE_VERSION_PATCH = 0; + + constexpr int SPARROW_PYCAPSULE_BINARY_CURRENT = 1; + constexpr int SPARROW_PYCAPSULE_BINARY_REVISION = 0; + constexpr int SPARROW_PYCAPSULE_BINARY_AGE = 0; + + static_assert( + SPARROW_PYCAPSULE_BINARY_AGE <= SPARROW_PYCAPSULE_BINARY_CURRENT, + "SPARROW_PYCAPSULE_BINARY_AGE cannot be greater than SPARROW_PYCAPSULE_BINARY_CURRENT" + ); +} diff --git a/include/sparrow-pycapsule/pycapsule.hpp b/include/sparrow-pycapsule/pycapsule.hpp new file mode 100644 index 0000000..6464b59 --- /dev/null +++ b/include/sparrow-pycapsule/pycapsule.hpp @@ -0,0 +1,100 @@ +#pragma once + +#include + +#include +#include + +// Forward declarations to avoid including heavy headers +namespace sparrow +{ + class array; +} + +struct ArrowSchema; +struct ArrowArray; + +namespace sparrow::pycapsule +{ + /** + * @brief Capsule destructor for ArrowSchema PyCapsules. + * + * Calls the schema's release callback if not null, then frees the schema. + * This is used as the PyCapsule destructor to ensure proper cleanup. + * + * @param capsule The PyCapsule containing an ArrowSchema pointer + */ + SPARROW_PYCAPSULE_API void ReleaseArrowSchemaPyCapsule(PyObject* capsule); + + /** + * @brief Exports a sparrow array's schema to a PyCapsule. + * + * Creates a new ArrowSchema on the heap and transfers ownership from the array. + * The array is moved from and becomes invalid after this call. + * + * @param arr The sparrow array to export (will be moved from) + * @return A new PyCapsule containing the ArrowSchema, or nullptr on error + */ + SPARROW_PYCAPSULE_API PyObject* ExportArrowSchemaPyCapsule(array& arr); + + /** + * @brief Retrieves the ArrowSchema pointer from a PyCapsule. + * + * @param capsule The PyCapsule to extract the schema from + * @return Pointer to the ArrowSchema, or nullptr if the capsule is invalid (sets Python exception) + */ + SPARROW_PYCAPSULE_API ArrowSchema* GetArrowSchemaPyCapsule(PyObject* capsule); + + /** + * @brief Capsule destructor for ArrowArray PyCapsules. + * + * Calls the array's release callback if not null, then frees the array. + * This is used as the PyCapsule destructor to ensure proper cleanup. + * + * @param capsule The PyCapsule containing an ArrowArray pointer + */ + SPARROW_PYCAPSULE_API void ReleaseArrowArrayPyCapsule(PyObject* capsule); + + /** + * @brief Exports a sparrow array's data to a PyCapsule. + * + * Creates a new ArrowArray on the heap and transfers ownership from the array. + * The array is moved from and becomes invalid after this call. + * + * @param arr The sparrow array to export (will be moved from) + * @return A new PyCapsule containing the ArrowArray, or nullptr on error + */ + SPARROW_PYCAPSULE_API PyObject* ExportArrowArrayPyCapsule(array& arr); + + /** + * @brief Retrieves the ArrowArray pointer from a PyCapsule. + * + * @param capsule The PyCapsule to extract the array from + * @return Pointer to the ArrowArray, or nullptr if the capsule is invalid (sets Python exception) + */ + SPARROW_PYCAPSULE_API ArrowArray* GetArrowArrayPyCapsule(PyObject* capsule); + + /** + * @brief Imports a sparrow array from schema and array PyCapsules. + * + * Transfers ownership from the capsules to the returned array. + * After successful import, the capsules' release callbacks are set to nullptr, + * and the returned array owns the data. + * + * @param schema_capsule PyCapsule containing an ArrowSchema + * @param array_capsule PyCapsule containing an ArrowArray + * @return A sparrow array constructed from the capsules, or an empty array on error + */ + SPARROW_PYCAPSULE_API array import_array_from_capsules(PyObject* schema_capsule, PyObject* array_capsule); + + /** + * @brief Exports a sparrow array to both schema and array PyCapsules. + * + * This is the recommended way to export an array, as it creates both + * required capsules in one call. The array is moved from and becomes invalid. + * + * @param arr The sparrow array to export (will be moved from) + * @return A pair of (schema_capsule, array_capsule), or (nullptr, nullptr) on error + */ + SPARROW_PYCAPSULE_API std::pair export_array_to_capsules(array& arr); +} diff --git a/sparrow-pycapsuleConfig.cmake.in b/sparrow-pycapsuleConfig.cmake.in new file mode 100644 index 0000000..633f2cb --- /dev/null +++ b/sparrow-pycapsuleConfig.cmake.in @@ -0,0 +1,16 @@ +# sparrow-pycapsule cmake module +# This module sets the following variables in your project:: +# +# sparrow-pycapsule_FOUND - true if sparrow found on the system +# sparrow-pycapsule_INCLUDE_DIRS - the directory containing sparrow headers +# sparrow-pycapsule_LIBRARY - empty + +@PACKAGE_INIT@ + +include(CMakeFindDependencyMacro) + +if(NOT TARGET sparrow::sparrow-pycapsule ) + include("${CMAKE_CURRENT_LIST_DIR}/@PROJECT_NAME@Targets.cmake") + get_target_property(@PROJECT_NAME@_INCLUDE_DIRS sparrow::sparrow-pycapsule INTERFACE_INCLUDE_DIRECTORIES) + get_target_property(@PROJECT_NAME@_LIBRARY sparrow::sparrow-pycapsule LOCATION) +endif() diff --git a/src/pycapsule.cpp b/src/pycapsule.cpp new file mode 100644 index 0000000..d5278f8 --- /dev/null +++ b/src/pycapsule.cpp @@ -0,0 +1,109 @@ +#include + +#include + +#include +#include + + +namespace sparrow::pycapsule +{ + namespace + { + // Internal capsule name constants + constexpr std::string_view arrow_schema_str = "arrow_schema"; + constexpr std::string_view arrow_array_str = "arrow_array"; + } + + void ReleaseArrowSchemaPyCapsule(PyObject* capsule) + { + auto schema = static_cast(PyCapsule_GetPointer(capsule, arrow_schema_str.data())); + if (schema->release != nullptr) + { + schema->release(schema); + } + delete schema; + } + + PyObject* ExportArrowSchemaPyCapsule(array& arr) + { + // Allocate a new ArrowSchema on the heap and extract (move) the schema + ArrowSchema* arrow_schema_ptr = new ArrowSchema(); + *arrow_schema_ptr = extract_arrow_schema(std::move(arr)); + + return PyCapsule_New(arrow_schema_ptr, arrow_schema_str.data(), ReleaseArrowSchemaPyCapsule); + } + + ArrowSchema* GetArrowSchemaPyCapsule(PyObject* capsule) + { + return static_cast(PyCapsule_GetPointer(capsule, arrow_schema_str.data())); + } + + void ReleaseArrowArrayPyCapsule(PyObject* capsule) + { + auto array = static_cast(PyCapsule_GetPointer(capsule, arrow_array_str.data())); + if (array->release != nullptr) + { + array->release(array); + } + delete array; + } + + PyObject* ExportArrowArrayPyCapsule(array& arr) + { + // Allocate a new ArrowArray on the heap and extract (move) the array + ArrowArray* arrow_array_ptr = new ArrowArray(); + *arrow_array_ptr = extract_arrow_array(std::move(arr)); + + return PyCapsule_New(arrow_array_ptr, arrow_array_str.data(), ReleaseArrowArrayPyCapsule); + } + + ArrowArray* GetArrowArrayPyCapsule(PyObject* capsule) + { + return static_cast(PyCapsule_GetPointer(capsule, arrow_array_str.data())); + } + + array import_array_from_capsules(PyObject* schema_capsule, PyObject* array_capsule) + { + ArrowSchema* schema = GetArrowSchemaPyCapsule(schema_capsule); + if (schema == nullptr) + { + // Error already set by PyCapsule_GetPointer + return array{}; + } + + ArrowArray* arr = GetArrowArrayPyCapsule(array_capsule); + if (arr == nullptr) + { + // Error already set by PyCapsule_GetPointer + return array{}; + } + + // Move the data from the capsule structures + // The capsule destructors will still be called, but they will see + // that release is null and won't do anything + ArrowSchema schema_moved = *schema; + ArrowArray array_moved = *arr; + + // Mark as released to prevent the capsule destructors from freeing the data + schema->release = nullptr; + arr->release = nullptr; + + return array(std::move(array_moved), std::move(schema_moved)); + } + + std::pair export_array_to_capsules(array& arr) + { + // Extract both schema and array from the sparrow array (moves ownership) + auto [arrow_array, arrow_schema] = extract_arrow_structures(std::move(arr)); + + // Allocate heap copies for the PyCapsules + ArrowSchema* schema_ptr = new ArrowSchema(std::move(arrow_schema)); + ArrowArray* array_ptr = new ArrowArray(std::move(arrow_array)); + + PyObject* schema_capsule = PyCapsule_New(schema_ptr, arrow_schema_str.data(), ReleaseArrowSchemaPyCapsule); + PyObject* array_capsule = PyCapsule_New(array_ptr, arrow_array_str.data(), ReleaseArrowArrayPyCapsule); + + return {schema_capsule, array_capsule}; + } +} \ No newline at end of file diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt new file mode 100644 index 0000000..0e7353f --- /dev/null +++ b/test/CMakeLists.txt @@ -0,0 +1,86 @@ +enable_testing() + +if(NOT CMAKE_BUILD_TYPE) + message(STATUS "Setting tests build type to Release") + set(CMAKE_BUILD_TYPE Release CACHE STRING "Choose the type of build." FORCE) +else() + message(STATUS "Tests build type is ${CMAKE_BUILD_TYPE}") +endif() + +set(SPARROW_PYCAPSULE_TESTS_SOURCES + main.cpp + test_pycapsule.cpp +) + +set(test_target test_sparrow_pycapsule_lib) + +add_executable(${test_target} ${SPARROW_PYCAPSULE_TESTS_SOURCES}) + +target_link_libraries(${test_target} + PRIVATE + sparrow-pycapsule + sparrow::sparrow + doctest::doctest + Python::Python +) + +if(MSVC) + target_compile_options(${test_target} PRIVATE /W4) +else() + target_compile_options(${test_target} PRIVATE -Wall -Wextra -Wpedantic) +endif() + +target_compile_features(${test_target} PRIVATE cxx_std_20) + +if(ENABLE_COVERAGE) + enable_coverage(${test_target}) +endif() + +add_test(NAME ${test_target} COMMAND ${test_target}) + +# For better output formatting +set_tests_properties(${test_target} PROPERTIES + TIMEOUT 300 +) + +# Add a custom target to run tests +add_custom_target(run_pycapsule_tests + COMMAND ${CMAKE_CTEST_COMMAND} --output-on-failure + DEPENDS ${test_target} + WORKING_DIRECTORY ${CMAKE_BINARY_DIR} + COMMENT "Running pycapsule tests" +) + +if(CMAKE_COMPILER_IS_GNUCXX OR CMAKE_CXX_COMPILER_ID MATCHES "Clang") + # Disable some warnings for test code + target_compile_options(${test_target} PRIVATE + -Wno-unused-variable + -Wno-unused-parameter + ) +endif() + +set_target_properties(${test_target} PROPERTIES + FOLDER tests +) + +add_custom_target(run_tests + COMMAND ${test_target} + DEPENDS + ${test_target} + COMMENT "Running tests" + USES_TERMINAL +) + +set_target_properties(run_tests PROPERTIES FOLDER "Tests utilities") + +set(JUNIT_REPORT_FILE_DOCTEST ${CMAKE_CURRENT_BINARY_DIR}/test_sparrow-pycapsule_lib_report_doctest.xml) + +add_custom_target(run_tests_with_junit_report + COMMAND ${test_target} --reporters=junit --out=${JUNIT_REPORT_FILE_DOCTEST} --no-path-filenames=true + DEPENDS + ${test_target} + COMMENT "Running tests with JUnit reports saved to: ${JUNIT_REPORT_FILE_DOCTEST}" + USES_TERMINAL +) + +set_target_properties(run_tests_with_junit_report PROPERTIES FOLDER "Tests utilities") diff --git a/test/main.cpp b/test/main.cpp new file mode 100644 index 0000000..9522fa7 --- /dev/null +++ b/test/main.cpp @@ -0,0 +1,2 @@ +#define DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN +#include "doctest/doctest.h" diff --git a/test/test_pycapsule.cpp b/test/test_pycapsule.cpp new file mode 100644 index 0000000..06a46a7 --- /dev/null +++ b/test/test_pycapsule.cpp @@ -0,0 +1,469 @@ +#include + +#include +#include +#include + +#include "doctest/doctest.h" + +namespace sparrow::pycapsule +{ + // Helper function to create a simple test array + sparrow::array make_test_array() + { + std::vector> values = { + sparrow::make_nullable(1, true), + sparrow::make_nullable(2, true), + sparrow::make_nullable(0, false), // null value + sparrow::make_nullable(4, true), + sparrow::make_nullable(5, true) + }; + + sparrow::primitive_array prim_array(std::move(values)); + return sparrow::array(std::move(prim_array)); + } + + // RAII wrapper for Python initialization + struct PythonInitializer + { + PythonInitializer() + { + if (!Py_IsInitialized()) + { + Py_Initialize(); + } + } + + ~PythonInitializer() + { + // Note: We don't call Py_Finalize() here as it might interfere + // with other tests or cause issues with static cleanup + } + + // Prevent copying and moving + PythonInitializer(const PythonInitializer&) = delete; + PythonInitializer& operator=(const PythonInitializer&) = delete; + PythonInitializer(PythonInitializer&&) = delete; + PythonInitializer& operator=(PythonInitializer&&) = delete; + }; + + // RAII wrapper for PyObject* + struct PyObjectDeleter + { + void operator()(PyObject* obj) const + { + if (obj != nullptr) + { + Py_DECREF(obj); + } + } + }; + + using PyObjectPtr = std::unique_ptr; + + TEST_SUITE("pycapsule") + { + TEST_CASE("ExportArrowSchemaPyCapsule") + { + PythonInitializer py_init; + + SUBCASE("creates_valid_capsule") + { + auto arr = make_test_array(); + // Note: ExportArrowSchemaPyCapsule moves from arr, so arr becomes invalid after + PyObject* schema_capsule = ExportArrowSchemaPyCapsule(arr); + + REQUIRE_NE(schema_capsule, nullptr); + CHECK(PyCapsule_CheckExact(schema_capsule)); + + const char* name = PyCapsule_GetName(schema_capsule); + REQUIRE_NE(name, nullptr); + CHECK_EQ(std::string(name), "arrow_schema"); + + // Verify we can get the pointer + ArrowSchema* schema = static_cast( + PyCapsule_GetPointer(schema_capsule, "arrow_schema") + ); + CHECK_NE(schema, nullptr); + CHECK_NE(schema->release, nullptr); + + Py_DECREF(schema_capsule); + } + + SUBCASE("capsule_has_destructor") + { + auto arr = make_test_array(); + PyObject* schema_capsule = ExportArrowSchemaPyCapsule(arr); + + REQUIRE_NE(schema_capsule, nullptr); + + // Get the schema pointer before destruction + ArrowSchema* schema = static_cast( + PyCapsule_GetPointer(schema_capsule, "arrow_schema") + ); + REQUIRE_NE(schema, nullptr); + + // The destructor should be set + PyCapsule_Destructor destructor = PyCapsule_GetDestructor(schema_capsule); + CHECK_NE(destructor, nullptr); + + // Decref will call the destructor + Py_DECREF(schema_capsule); + } + } + + TEST_CASE("ExportArrowArrayPyCapsule") + { + PythonInitializer py_init; + + SUBCASE("creates_valid_capsule") + { + auto arr = make_test_array(); + PyObject* array_capsule = ExportArrowArrayPyCapsule(arr); + + REQUIRE_NE(array_capsule, nullptr); + CHECK(PyCapsule_CheckExact(array_capsule)); + + const char* name = PyCapsule_GetName(array_capsule); + REQUIRE_NE(name, nullptr); + CHECK_EQ(std::string(name), "arrow_array"); + + // Verify we can get the pointer + ArrowArray* array = static_cast(PyCapsule_GetPointer(array_capsule, "arrow_array")); + CHECK_NE(array, nullptr); + CHECK_NE(array->release, nullptr); + + Py_DECREF(array_capsule); + } + + SUBCASE("capsule_has_destructor") + { + auto arr = make_test_array(); + PyObject* array_capsule = ExportArrowArrayPyCapsule(arr); + + REQUIRE_NE(array_capsule, nullptr); + + // Get the array pointer before destruction + ArrowArray* array = static_cast(PyCapsule_GetPointer(array_capsule, "arrow_array")); + REQUIRE_NE(array, nullptr); + + // The destructor should be set + PyCapsule_Destructor destructor = PyCapsule_GetDestructor(array_capsule); + CHECK_NE(destructor, nullptr); + + // Decref will call the destructor + Py_DECREF(array_capsule); + } + + SUBCASE("array_has_correct_length") + { + auto arr = make_test_array(); + PyObject* array_capsule = ExportArrowArrayPyCapsule(arr); + + REQUIRE_NE(array_capsule, nullptr); + + ArrowArray* array = static_cast(PyCapsule_GetPointer(array_capsule, "arrow_array")); + REQUIRE_NE(array, nullptr); + CHECK_EQ(array->length, 5); + + Py_DECREF(array_capsule); + } + } + + TEST_CASE("GetArrowSchemaPyCapsule") + { + PythonInitializer py_init; + + SUBCASE("returns_valid_schema_pointer") + { + auto arr = make_test_array(); + PyObject* schema_capsule = ExportArrowSchemaPyCapsule(arr); + + ArrowSchema* schema = GetArrowSchemaPyCapsule(schema_capsule); + CHECK_NE(schema, nullptr); + CHECK_NE(schema->release, nullptr); + + Py_DECREF(schema_capsule); + } + + SUBCASE("returns_null_for_wrong_capsule_name") + { + // Create a capsule with wrong name + int dummy = 42; + PyObject* wrong_capsule = PyCapsule_New(&dummy, "wrong_name", nullptr); + + ArrowSchema* schema = GetArrowSchemaPyCapsule(wrong_capsule); + CHECK_EQ(schema, nullptr); + CHECK_NE(PyErr_Occurred(), nullptr); + PyErr_Clear(); + + Py_DECREF(wrong_capsule); + } + + SUBCASE("returns_null_for_non_capsule") + { + PyObject* not_capsule = PyLong_FromLong(42); + + ArrowSchema* schema = GetArrowSchemaPyCapsule(not_capsule); + CHECK_EQ(schema, nullptr); + CHECK_NE(PyErr_Occurred(), nullptr); + PyErr_Clear(); + + Py_DECREF(not_capsule); + } + } + + TEST_CASE("GetArrowArrayPyCapsule") + { + PythonInitializer py_init; + + SUBCASE("returns_valid_array_pointer") + { + auto arr = make_test_array(); + PyObject* array_capsule = ExportArrowArrayPyCapsule(arr); + + ArrowArray* array = GetArrowArrayPyCapsule(array_capsule); + CHECK_NE(array, nullptr); + CHECK_NE(array->release, nullptr); + + Py_DECREF(array_capsule); + } + + SUBCASE("returns_null_for_wrong_capsule_name") + { + // Create a capsule with wrong name + int dummy = 42; + PyObject* wrong_capsule = PyCapsule_New(&dummy, "wrong_name", nullptr); + + ArrowArray* array = GetArrowArrayPyCapsule(wrong_capsule); + CHECK_EQ(array, nullptr); + CHECK_NE(PyErr_Occurred(), nullptr); + PyErr_Clear(); + + Py_DECREF(wrong_capsule); + } + + SUBCASE("returns_null_for_non_capsule") + { + PyObject* not_capsule = PyLong_FromLong(42); + + ArrowArray* array = GetArrowArrayPyCapsule(not_capsule); + CHECK_EQ(array, nullptr); + CHECK_NE(PyErr_Occurred(), nullptr); + PyErr_Clear(); + + Py_DECREF(not_capsule); + } + } + + TEST_CASE("export_array_to_capsules") + { + PythonInitializer py_init; + + SUBCASE("exports_both_schema_and_array") + { + auto arr = make_test_array(); + auto [schema_capsule, array_capsule] = export_array_to_capsules(arr); + + REQUIRE_NE(schema_capsule, nullptr); + REQUIRE_NE(array_capsule, nullptr); + + CHECK(PyCapsule_CheckExact(schema_capsule)); + CHECK(PyCapsule_CheckExact(array_capsule)); + + CHECK_EQ(std::string(PyCapsule_GetName(schema_capsule)), "arrow_schema"); + CHECK_EQ(std::string(PyCapsule_GetName(array_capsule)), "arrow_array"); + + Py_DECREF(schema_capsule); + Py_DECREF(array_capsule); + } + + SUBCASE("exported_capsules_contain_valid_data") + { + auto arr = make_test_array(); + auto [schema_capsule, array_capsule] = export_array_to_capsules(arr); + + ArrowSchema* schema = GetArrowSchemaPyCapsule(schema_capsule); + ArrowArray* array = GetArrowArrayPyCapsule(array_capsule); + + REQUIRE_NE(schema, nullptr); + REQUIRE_NE(array, nullptr); + + CHECK_NE(schema->release, nullptr); + CHECK_NE(array->release, nullptr); + CHECK_EQ(array->length, 5); + + Py_DECREF(schema_capsule); + Py_DECREF(array_capsule); + } + } + + TEST_CASE("import_array_from_capsules") + { + PythonInitializer py_init; + + SUBCASE("imports_valid_capsules") + { + // Export an array + auto original_arr = make_test_array(); + auto [schema_capsule, array_capsule] = export_array_to_capsules(original_arr); + + // Import it back + auto imported_arr = import_array_from_capsules(schema_capsule, array_capsule); + + // Verify the imported array + CHECK_EQ(imported_arr.size(), 5); + + // The capsules should still be valid but the release callbacks should be null + ArrowSchema* schema = static_cast( + PyCapsule_GetPointer(schema_capsule, "arrow_schema") + ); + ArrowArray* array = static_cast(PyCapsule_GetPointer(array_capsule, "arrow_array")); + + CHECK_EQ(schema->release, nullptr); + CHECK_EQ(array->release, nullptr); + + Py_DECREF(schema_capsule); + Py_DECREF(array_capsule); + } + + // Note: Error handling tests for invalid capsules are omitted + // as they would require more complex setup to avoid crashes + + SUBCASE("ownership_transfer_is_correct") + { + // Export an array + auto original_arr = make_test_array(); + auto [schema_capsule, array_capsule] = export_array_to_capsules(original_arr); + + // Get pointers before import + ArrowSchema* schema_before = static_cast( + PyCapsule_GetPointer(schema_capsule, "arrow_schema") + ); + ArrowArray* array_before = static_cast( + PyCapsule_GetPointer(array_capsule, "arrow_array") + ); + + CHECK_NE(schema_before->release, nullptr); + CHECK_NE(array_before->release, nullptr); + + // Import (transfers ownership) + auto imported_arr = import_array_from_capsules(schema_capsule, array_capsule); + + // After import, release callbacks should be null + CHECK_EQ(schema_before->release, nullptr); + CHECK_EQ(array_before->release, nullptr); + + // The capsule destructors should now be no-ops + Py_DECREF(schema_capsule); + Py_DECREF(array_capsule); + + // The imported array should still be valid + CHECK(imported_arr.size() == 5); + } + } + + TEST_CASE("round_trip_export_import") + { + PythonInitializer py_init; + + SUBCASE("preserves_array_data") + { + // Create original array + std::vector> values = { + sparrow::make_nullable(10, true), + sparrow::make_nullable(20, true), + sparrow::make_nullable(0, false), + sparrow::make_nullable(40, true), + sparrow::make_nullable(50, true) + }; + + sparrow::primitive_array prim_array(std::move(values)); + sparrow::array original_arr(std::move(prim_array)); + + size_t original_size = original_arr.size(); // Save size before move + + // Export (moves from original_arr) + auto [schema_capsule, array_capsule] = export_array_to_capsules(original_arr); + + // Import + auto imported_arr = import_array_from_capsules(schema_capsule, array_capsule); + + // Verify + REQUIRE_EQ(imported_arr.size(), 5); + CHECK_EQ(imported_arr.size(), original_size); + + Py_DECREF(schema_capsule); + Py_DECREF(array_capsule); + } + } + + TEST_CASE("ReleaseArrowSchemaPyCapsule_handles_null_release") + { + PythonInitializer py_init; + + SUBCASE("destructor_handles_already_released_schema") + { + // Create a schema with null release callback + ArrowSchema* schema = new ArrowSchema(); + schema->release = nullptr; + + PyObject* capsule = PyCapsule_New(schema, "arrow_schema", ReleaseArrowSchemaPyCapsule); + + // This should not crash + Py_DECREF(capsule); + } + } + + TEST_CASE("ReleaseArrowArrayPyCapsule_handles_null_release") + { + PythonInitializer py_init; + + SUBCASE("destructor_handles_already_released_array") + { + // Create an array with null release callback + ArrowArray* array = new ArrowArray(); + array->release = nullptr; + + PyObject* capsule = PyCapsule_New(array, "arrow_array", ReleaseArrowArrayPyCapsule); + + // This should not crash + Py_DECREF(capsule); + } + } + + TEST_CASE("memory_leak_prevention") + { + PythonInitializer py_init; + + SUBCASE("capsule_destructor_prevents_leak_if_never_consumed") + { + // Create capsules but never consume them + auto arr = make_test_array(); + PyObject* schema_capsule = ExportArrowSchemaPyCapsule(arr); + PyObject* array_capsule = ExportArrowArrayPyCapsule(arr); + + // Just decref without consuming - destructors should clean up + Py_DECREF(schema_capsule); + Py_DECREF(array_capsule); + } + + SUBCASE("imported_array_manages_memory_correctly") + { + { + auto original_arr = make_test_array(); + auto [schema_capsule, array_capsule] = export_array_to_capsules(original_arr); + + { + auto imported_arr = import_array_from_capsules(schema_capsule, array_capsule); + // imported_arr goes out of scope here + } + + // Capsules still need cleanup + Py_DECREF(schema_capsule); + Py_DECREF(array_capsule); + } + } + } + } +}