From 847f11b0b086f8b7b733eb07b983320bfffc891a Mon Sep 17 00:00:00 2001 From: HugoFara Date: Tue, 28 Jul 2026 17:38:57 +0200 Subject: [PATCH 1/2] feat(packaging): build self-contained Python wheels for Linux and macOS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `pip install forefire` now works, providing both the pyforefire module and the forefire command-line interpreter with NetCDF vendored in. Closes #149. Packaging moves to the repository root and is driven by scikit-build-core, which runs the existing CMake build instead of assuming a pre-built libforefireL: an sdist rooted at bindings/python cannot reach ../../src. bindings/python/{setup.py,pyproject.toml} are removed. CMakeLists.txt becomes option-driven, keeping today's behaviour as the default for source builds while wheels get portable settings: FOREFIRE_ENABLE_MPI, FOREFIRE_NATIVE_ARCH, FOREFIRE_BUILD_PYTHON, FOREFIRE_STATIC_CORE, FOREFIRE_BUILD_TOOLS, FOREFIRE_CHECK_LFS Wheels disable MPI so a published artifact never depends on what happened to be installed on the builder (see #102), and disable -march=native, which was previously unconditional and made any redistributed binary — including the published Docker image — crash with SIGILL on older CPUs. NetCDF is now located with find_path/find_library behind a single forefire_netcdf interface target rather than bare link_libraries. This is what makes a macOS wheel possible: Homebrew names the library libnetcdf-cxx4, not libnetcdf_c++4, and /opt/homebrew is off the default search path. Missing NetCDF now fails at configure time with install hints. The core is an OBJECT library, not STATIC, when linked directly into the artifacts: propagation and flux models register themselves from static initialisers that nothing references by name, and a static archive lets the linker discard them, leaving the model table empty at runtime. Other changes: - version is parsed from src/include/Version.h, so project(VERSION), CPack and the wheel all track the file tools/increment-version.sh edits - .gitignore no longer ignores CMakeLists.txt, which had been dropping the build file from the sdist - Dockerfile builds its wheel from the root and installs the interpreter from that one wheel instead of copying a second binary - tests/python/test_wheel.py smoke-tests an installed wheel; every wheel is tested before upload --- .github/workflows/wheels.yml | 103 ++++++++ .gitignore | 7 +- CMakeLists.txt | 308 +++++++++++++++-------- Dockerfile | 14 +- README.md | 50 +++- bindings/python/README.md | 191 ++++++++------ bindings/python/pyproject.toml | 33 --- bindings/python/setup.py | 111 -------- pyproject.toml | 101 ++++++++ tests/python/test_wheel.py | 96 +++++++ tools/devops/install-netcdf-manylinux.sh | 51 ++++ 11 files changed, 731 insertions(+), 334 deletions(-) create mode 100644 .github/workflows/wheels.yml delete mode 100644 bindings/python/pyproject.toml delete mode 100644 bindings/python/setup.py create mode 100644 pyproject.toml create mode 100644 tests/python/test_wheel.py create mode 100644 tools/devops/install-netcdf-manylinux.sh diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml new file mode 100644 index 00000000..e7ff1e21 --- /dev/null +++ b/.github/workflows/wheels.yml @@ -0,0 +1,103 @@ +name: 🐍 Build Python wheels + +on: + push: + branches: + - "master" + tags: + - "v*" + pull_request: + branches: [ "master" ] + release: + types: [ published ] + workflow_dispatch: + +concurrency: + group: wheels-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + build-wheels: + name: Wheels (${{ matrix.arch }} on ${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + arch: x86_64 + - os: ubuntu-24.04-arm + arch: aarch64 + - os: macos-14 + arch: arm64 + - os: macos-13 + arch: x86_64 + steps: + # No `lfs: true` here: wheels are built from source only, and the LFS + # fixtures are ~60 MB that no wheel job reads. + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Build wheels + uses: pypa/cibuildwheel@v4.1.1 + env: + CIBW_ARCHS: ${{ matrix.arch }} + + - name: Upload wheels + uses: actions/upload-artifact@v7 + with: + name: wheels-${{ matrix.os }}-${{ matrix.arch }} + path: ./wheelhouse/*.whl + + build-sdist: + name: Source distribution + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Build sdist + run: pipx run build --sdist + + - name: Install NetCDF (to compile the sdist) + run: | + sudo apt-get update -y + sudo apt-get install -y --no-install-recommends \ + build-essential cmake libnetcdf-dev libnetcdf-c++4-dev + + # Building the sdist end to end is the only check that someone on an + # unsupported platform can still `pip install forefire`. + - name: Install and smoke test the sdist + run: | + pip install dist/*.tar.gz + python tests/python/test_wheel.py + forefire -v + + - name: Upload sdist + uses: actions/upload-artifact@v7 + with: + name: sdist + path: dist/*.tar.gz + + publish: + name: Publish to PyPI + needs: [ build-wheels, build-sdist ] + runs-on: ubuntu-latest + if: github.event_name == 'release' && github.event.action == 'published' + environment: + name: pypi + url: https://pypi.org/p/forefire + # Trusted publishing: no API token to rotate, but the `forefire` PyPI + # project must first declare this repository and workflow as a trusted + # publisher (https://docs.pypi.org/trusted-publishers/). + permissions: + id-token: write + steps: + - name: Download all artifacts + uses: actions/download-artifact@v8 + with: + path: dist + merge-multiple: true + + - name: Publish + uses: pypa/gh-action-pypi-publish@v1.14.1 diff --git a/.gitignore b/.gitignore index 6a6cc9d7..307d470e 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,10 @@ tools/cache tools/cmaps bindings/python/src/pyforefire.egg-info paper/jats +#python packaging +dist +wheelhouse +*.egg-info #python __pycache__ venv @@ -32,7 +36,8 @@ docs/doxygen/xml docs/doxygen/html # build -CMakeLists.txt +# Do not ignore CMakeLists.txt here: it is tracked, and ignoring it makes +# packaging tools drop it from the sdist. build.sh #tools diff --git a/CMakeLists.txt b/CMakeLists.txt index 7cb0b588..af95685c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,5 +1,52 @@ -cmake_minimum_required(VERSION 3.10) -project(forefire VERSION 1.0) +cmake_minimum_required(VERSION 3.15) + +# ---------------------------------- +# Version (single source of truth: src/include/Version.h) +# ---------------------------------- +file(READ "${CMAKE_CURRENT_SOURCE_DIR}/src/include/Version.h" _ff_version_header) +string(REGEX MATCH "ff_version[ \t]*=[ \t]*\"v?([0-9]+\\.[0-9]+\\.[0-9]+)\"" _ff_version_match "${_ff_version_header}") +if(NOT _ff_version_match) + message(FATAL_ERROR "Could not parse ff_version from src/include/Version.h") +endif() +set(FOREFIRE_VERSION "${CMAKE_MATCH_1}") + +project(forefire VERSION ${FOREFIRE_VERSION}) +message(STATUS "ForeFire version: ${FOREFIRE_VERSION}") + +# ---------------------------------- +# Build Options +# ---------------------------------- +# `SKBUILD` is defined when CMake is driven by scikit-build-core, i.e. when a +# Python wheel is being built. Wheels have to run on machines other than the +# builder, so they default to no `-march=native` (which would otherwise crash +# with SIGILL on older CPUs) and no MPI, since auto-detected MPI support is a +# build-environment accident rather than a user choice (see issue #102). +# Every default below can still be overridden explicitly, through +# `tool.scikit-build.cmake.define` or -D on the command line. +if(DEFINED SKBUILD) + set(_ff_wheel_build ON) + set(_ff_default_mpi OFF) + set(_ff_default_native OFF) + set(_ff_default_python ON) + set(_ff_default_static ON) + set(_ff_default_tools OFF) + set(_ff_default_lfs OFF) +else() + set(_ff_wheel_build OFF) + set(_ff_default_mpi ON) + set(_ff_default_native ON) + set(_ff_default_python OFF) + set(_ff_default_static OFF) + set(_ff_default_tools ON) + set(_ff_default_lfs ON) +endif() + +option(FOREFIRE_ENABLE_MPI "Enable MPI coupling when MPI is available" ${_ff_default_mpi}) +option(FOREFIRE_NATIVE_ARCH "Optimise for the build machine's CPU (-march=native)" ${_ff_default_native}) +option(FOREFIRE_BUILD_PYTHON "Build the pyforefire Python extension module" ${_ff_default_python}) +option(FOREFIRE_STATIC_CORE "Build the ForeFire core as a static library" ${_ff_default_static}) +option(FOREFIRE_BUILD_TOOLS "Build the ANN_test helper executable" ${_ff_default_tools}) +option(FOREFIRE_CHECK_LFS "Run the Git LFS data integrity check at configure time" ${_ff_default_lfs}) # ---------------------------------- # Set C++ Standard @@ -10,7 +57,10 @@ set(CMAKE_CXX_STANDARD_REQUIRED ON) # ---------------------------------- # MPI Detection # ---------------------------------- -if(MPI_C_COMPILER AND MPI_CXX_COMPILER) +if(NOT FOREFIRE_ENABLE_MPI) + message(STATUS "MPI support disabled (FOREFIRE_ENABLE_MPI=OFF)") + set(MPI_FOUND FALSE) +elseif(MPI_C_COMPILER AND MPI_CXX_COMPILER) message(STATUS "Using manually specified MPI compilers:") message(STATUS " MPI_C_COMPILER: ${MPI_C_COMPILER}") message(STATUS " MPI_CXX_COMPILER: ${MPI_CXX_COMPILER}") @@ -74,8 +124,12 @@ else() endif() # ---------------------------------- -# Check for MESONH and XYZ Environment Variables +# NetCDF Dependency # ---------------------------------- +# Everything that needs NetCDF links against this interface target, so the +# detection logic below lives in exactly one place. +add_library(forefire_netcdf INTERFACE) + if(DEFINED ENV{SRC_MESONH} AND DEFINED ENV{XYZ} AND DEFINED ENV{FF_STATIC}) message(STATUS "MESONH and XYZ environment variables are set. FF_STATIC too, static NetCDF.") set(MESONH $ENV{SRC_MESONH}) @@ -87,13 +141,8 @@ if(DEFINED ENV{SRC_MESONH} AND DEFINED ENV{XYZ} AND DEFINED ENV{FF_STATIC}) else() message(FATAL_ERROR "No NETCDF-* directory found under ${MESONH}/src/dir_obj${XYZ}/MASTER/") endif() - include_directories(${NETCDF_HOME}/include) find_package(ZLIB REQUIRED) - if(ZLIB_FOUND) - message(STATUS "ZLIB found: ${ZLIB_LIBRARIES}") - else() - message(FATAL_ERROR "ZLIB not found") - endif() + message(STATUS "ZLIB found: ${ZLIB_LIBRARIES}") set(NETCDF_LIB "${NETCDF_HOME}/lib64/libnetcdf.a") set(NETCDF_CXX_LIB "${NETCDF_HOME}/lib64/libnetcdf_c++4.a") set(HDF5_LIB "${NETCDF_HOME}/lib64/libhdf5.a") @@ -105,7 +154,8 @@ if(DEFINED ENV{SRC_MESONH} AND DEFINED ENV{XYZ} AND DEFINED ENV{FF_STATIC}) message(FATAL_ERROR "Required static library not found: ${lib}") endif() endforeach() - set(NETCDF_STATIC_LIBS + target_include_directories(forefire_netcdf INTERFACE "${NETCDF_HOME}/include") + target_link_libraries(forefire_netcdf INTERFACE ${NETCDF_LIB} ${NETCDF_CXX_LIB} ${HDF5_HL_LIB} @@ -115,43 +165,75 @@ if(DEFINED ENV{SRC_MESONH} AND DEFINED ENV{XYZ} AND DEFINED ENV{FF_STATIC}) ${ZLIB_LIBRARIES} ) set(CMAKE_FIND_LIBRARY_SUFFIXES ".a") - set(BUILD_SHARED_LIBS OFF) else() - if(DEFINED ENV{NETCDF_HOME}) + # NETCDF_HOME / NETCDF_CXX_HOME may come from the environment or from + # -DNETCDF_HOME=... on the command line; both are only hints, system paths + # are searched as well. + if(NOT NETCDF_HOME AND DEFINED ENV{NETCDF_HOME}) set(NETCDF_HOME $ENV{NETCDF_HOME}) - include_directories(${NETCDF_HOME}/include) - link_directories(${NETCDF_HOME}/lib) - message(STATUS "NETCDF_HOME set to ${NETCDF_HOME}") - else() - message(STATUS "NETCDF_HOME not set. Attempting default path or system library paths.") endif() - if(MPI_FOUND) - link_libraries(netcdf netcdf_c++4 ${MPI_LIBRARIES}) - else() - link_libraries(netcdf netcdf_c++4) + if(NOT NETCDF_CXX_HOME AND DEFINED ENV{NETCDF_CXX_HOME}) + set(NETCDF_CXX_HOME $ENV{NETCDF_CXX_HOME}) endif() + set(_netcdf_hints ${NETCDF_HOME} ${NETCDF_CXX_HOME}) + + find_path(NETCDF_INCLUDE_DIR NAMES netcdf.h HINTS ${_netcdf_hints} PATH_SUFFIXES include) + find_library(NETCDF_LIBRARY NAMES netcdf HINTS ${_netcdf_hints} PATH_SUFFIXES lib lib64) + # The legacy C++4 API ships the umbrella header `netcdf` (no extension) + # next to ncFile.h, and is named netcdf_c++4 everywhere except Homebrew, + # which calls it netcdf-cxx4 (older bottles: netcdf-cxx). + find_path(NETCDF_CXX_INCLUDE_DIR NAMES ncFile.h HINTS ${_netcdf_hints} PATH_SUFFIXES include) + find_library(NETCDF_CXX_LIBRARY NAMES netcdf_c++4 netcdf-cxx4 netcdf-cxx HINTS ${_netcdf_hints} PATH_SUFFIXES lib lib64) + + foreach(_var NETCDF_INCLUDE_DIR NETCDF_LIBRARY NETCDF_CXX_INCLUDE_DIR NETCDF_CXX_LIBRARY) + if(NOT ${_var}) + message(FATAL_ERROR + "NetCDF not found (${_var} is missing).\n" + "ForeFire needs both the NetCDF C library and the legacy C++4 API:\n" + " Debian/Ubuntu: apt install libnetcdf-dev libnetcdf-c++4-dev\n" + " Fedora/RHEL: dnf install netcdf-devel netcdf-cxx4-devel\n" + " macOS: brew install netcdf netcdf-cxx\n" + "Set -DNETCDF_HOME=... (and -DNETCDF_CXX_HOME=... if the C++ API " + "lives elsewhere) to point at a custom installation.") + endif() + endforeach() + + message(STATUS "NetCDF C: ${NETCDF_LIBRARY} (headers: ${NETCDF_INCLUDE_DIR})") + message(STATUS "NetCDF C++: ${NETCDF_CXX_LIBRARY} (headers: ${NETCDF_CXX_INCLUDE_DIR})") + target_include_directories(forefire_netcdf INTERFACE ${NETCDF_CXX_INCLUDE_DIR} ${NETCDF_INCLUDE_DIR}) + target_link_libraries(forefire_netcdf INTERFACE ${NETCDF_CXX_LIBRARY} ${NETCDF_LIBRARY}) +endif() + +if(MPI_FOUND) + target_link_libraries(forefire_netcdf INTERFACE ${MPI_LIBRARIES}) endif() # ---------------------------------- # Compiler Flags # ---------------------------------- if(MPI_FOUND) - #set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -O3 -flto -fomit-frame-pointer -finline-functions -march=native -funroll-loops -ftree-vectorize -fno-strict-aliasing") set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -g -O0 -DMPI_COUPLING") else() - # uncoment if want to debug + # uncomment if want to debug # set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -g -O0") - set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -O3 -flto -fomit-frame-pointer -finline-functions -march=native -funroll-loops -ftree-vectorize -fno-strict-aliasing") + set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -O3 -flto -fomit-frame-pointer -finline-functions -funroll-loops -ftree-vectorize -fno-strict-aliasing") + if(FOREFIRE_NATIVE_ARCH) + set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -march=native") + endif() endif() # ---------------------------------- # Output Directories # ---------------------------------- -set(CMAKE_RUNTIME_OUTPUT_DIRECTORY "${CMAKE_SOURCE_DIR}/bin") -set(CMAKE_LIBRARY_OUTPUT_DIRECTORY "${CMAKE_SOURCE_DIR}/lib") -set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY "${CMAKE_SOURCE_DIR}/lib") -message(STATUS "CMAKE_RUNTIME_OUTPUT_DIRECTORY: ${CMAKE_RUNTIME_OUTPUT_DIRECTORY}") -message(STATUS "CMAKE_LIBRARY_OUTPUT_DIRECTORY: ${CMAKE_LIBRARY_OUTPUT_DIRECTORY}") +# Wheel builds must not write into the source tree; CMake's defaults (relative +# to the build directory) are what scikit-build-core expects. +if(NOT _ff_wheel_build) + set(CMAKE_RUNTIME_OUTPUT_DIRECTORY "${CMAKE_SOURCE_DIR}/bin") + set(CMAKE_LIBRARY_OUTPUT_DIRECTORY "${CMAKE_SOURCE_DIR}/lib") + set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY "${CMAKE_SOURCE_DIR}/lib") + message(STATUS "CMAKE_RUNTIME_OUTPUT_DIRECTORY: ${CMAKE_RUNTIME_OUTPUT_DIRECTORY}") + message(STATUS "CMAKE_LIBRARY_OUTPUT_DIRECTORY: ${CMAKE_LIBRARY_OUTPUT_DIRECTORY}") +endif() message(STATUS "CMAKE_CXX_FLAGS: ${CMAKE_CXX_FLAGS}") # ---------------------------------- @@ -160,106 +242,115 @@ message(STATUS "CMAKE_CXX_FLAGS: ${CMAKE_CXX_FLAGS}") file(GLOB_RECURSE SRC_FILES src/*.cpp) # ---------------------------------- -# Shared Library Target +# Core Library Target # ---------------------------------- -add_library(forefireL SHARED ${SRC_FILES}) -if(DEFINED NETCDF_STATIC_LIBS) - if(MPI_FOUND) - target_link_libraries(forefireL PRIVATE ${NETCDF_STATIC_LIBS} ${MPI_LIBRARIES}) - else() - target_link_libraries(forefireL PRIVATE ${NETCDF_STATIC_LIBS}) - endif() +if(FOREFIRE_STATIC_CORE) + # OBJECT, not STATIC: every propagation and flux model registers itself + # through a static initialiser that nothing references by name (see + # FireDomain::registerPropagationModelInstantiator). Out of a static + # archive the linker discards those objects and the models silently + # disappear at runtime; object libraries are always linked whole. + add_library(forefireL OBJECT ${SRC_FILES}) + set_target_properties(forefireL PROPERTIES POSITION_INDEPENDENT_CODE ON) else() - if(MPI_FOUND) - target_link_libraries(forefireL PRIVATE ${MPI_LIBRARIES}) - else() - target_link_libraries(forefireL PRIVATE netcdf netcdf_c++4) - endif() + add_library(forefireL SHARED ${SRC_FILES}) endif() +target_include_directories(forefireL PUBLIC + "${CMAKE_CURRENT_SOURCE_DIR}/src" + "${CMAKE_CURRENT_SOURCE_DIR}/src/include") +# Threads is explicit because a static core does not drag libpthread in for +# its consumers the way the shared library does. +find_package(Threads REQUIRED) +target_link_libraries(forefireL PUBLIC forefire_netcdf Threads::Threads) # ---------------------------------- # Test Executable # ---------------------------------- -set(TEST_MAIN tools/runANN/ANNTest.cpp) -add_executable(ANN_test ${TEST_MAIN}) -if(DEFINED NETCDF_STATIC_LIBS) - if(MPI_FOUND) - target_link_libraries(ANN_test PRIVATE ${NETCDF_STATIC_LIBS} ${MPI_LIBRARIES}) - else() - target_link_libraries(ANN_test PRIVATE ${NETCDF_STATIC_LIBS}) - endif() -else() - if(MPI_FOUND) - target_link_libraries(ANN_test PRIVATE ${MPI_LIBRARIES}) - else() - target_link_libraries(ANN_test PRIVATE netcdf netcdf_c++4) - endif() +if(FOREFIRE_BUILD_TOOLS) + add_executable(ANN_test tools/runANN/ANNTest.cpp) + target_link_libraries(ANN_test PRIVATE forefire_netcdf) endif() # ---------------------------------- # Main Project Executable # ---------------------------------- -set(INTERPRETER app/forefire/ForeFire.cpp app/forefire/AdvancedLineEditor.cpp) -add_executable(forefire ${INTERPRETER}) -if(DEFINED NETCDF_STATIC_LIBS) - if(MPI_FOUND) - target_link_libraries(forefire PRIVATE forefireL ${NETCDF_STATIC_LIBS} ${MPI_LIBRARIES}) - else() - target_link_libraries(forefire PRIVATE forefireL ${NETCDF_STATIC_LIBS}) - endif() -else() - if(MPI_FOUND) - target_link_libraries(forefire PRIVATE forefireL ${MPI_LIBRARIES}) - else() - target_link_libraries(forefire PRIVATE forefireL netcdf netcdf_c++4) +add_executable(forefire app/forefire/ForeFire.cpp app/forefire/AdvancedLineEditor.cpp) +target_link_libraries(forefire PRIVATE forefireL) + +# ---------------------------------- +# Python Extension Module +# ---------------------------------- +if(FOREFIRE_BUILD_PYTHON) + find_package(Python COMPONENTS Interpreter Development.Module REQUIRED) + find_package(pybind11 CONFIG REQUIRED) + + pybind11_add_module(_pyforefire bindings/python/src/pyforefire/_pyforefire.cpp) + target_link_libraries(_pyforefire PRIVATE forefireL) + install(TARGETS _pyforefire DESTINATION pyforefire) + + # Ship the command line interpreter alongside the module so that + # `pip install forefire` also provides a working `forefire` command. + # SKBUILD_SCRIPTS_DIR only exists when scikit-build-core is driving. + if(_ff_wheel_build) + install(TARGETS forefire RUNTIME DESTINATION ${SKBUILD_SCRIPTS_DIR}) endif() endif() # ---------------------------------- # CPack Configuration for DMG (macOS) # ---------------------------------- -include(InstallRequiredSystemLibraries) -set(CPACK_PACKAGE_NAME "forefire") -set(CPACK_PACKAGE_VENDOR "CNRS - University of Corsica") -set(CPACK_PACKAGE_DESCRIPTION_SUMMARY "Forefire - a wildfire solver") -set(CPACK_PACKAGE_VERSION ${PROJECT_VERSION}) -set(CPACK_PACKAGE_VERSION_MAJOR ${PROJECT_VERSION_MAJOR}) -set(CPACK_PACKAGE_VERSION_MINOR ${PROJECT_VERSION_MINOR}) -set(CPACK_PACKAGE_VERSION_PATCH ${PROJECT_VERSION_PATCH}) -set(CPACK_RESOURCE_FILE_LICENSE "${CMAKE_SOURCE_DIR}/LICENSE") # Adjust as necessary - -# Specify installation directories -install(TARGETS forefire forefireL ANN_test - RUNTIME DESTINATION bin - LIBRARY DESTINATION lib - ARCHIVE DESTINATION lib) - -# For macOS DMG packaging using the DragNDrop generator: -if(APPLE) - set(CPACK_GENERATOR "DragNDrop") - set(CPACK_DMG_VOLUME_NAME "forefire_${PROJECT_VERSION}") - # Optionally, customize DMG settings (background, icon, etc.) -endif() +if(NOT _ff_wheel_build) + include(InstallRequiredSystemLibraries) + set(CPACK_PACKAGE_NAME "forefire") + set(CPACK_PACKAGE_VENDOR "CNRS - University of Corsica") + set(CPACK_PACKAGE_DESCRIPTION_SUMMARY "Forefire - a wildfire solver") + set(CPACK_PACKAGE_VERSION ${PROJECT_VERSION}) + set(CPACK_PACKAGE_VERSION_MAJOR ${PROJECT_VERSION_MAJOR}) + set(CPACK_PACKAGE_VERSION_MINOR ${PROJECT_VERSION_MINOR}) + set(CPACK_PACKAGE_VERSION_PATCH ${PROJECT_VERSION_PATCH}) + set(CPACK_RESOURCE_FILE_LICENSE "${CMAKE_SOURCE_DIR}/LICENSE") # Adjust as necessary + + # Specify installation directories + install(TARGETS forefire RUNTIME DESTINATION bin) + if(NOT FOREFIRE_STATIC_CORE) + # With a static core there is no libforefireL to install: it is linked + # straight into the executables. + install(TARGETS forefireL + LIBRARY DESTINATION lib + ARCHIVE DESTINATION lib) + endif() + if(FOREFIRE_BUILD_TOOLS) + install(TARGETS ANN_test RUNTIME DESTINATION bin) + endif() + + # For macOS DMG packaging using the DragNDrop generator: + if(APPLE) + set(CPACK_GENERATOR "DragNDrop") + set(CPACK_DMG_VOLUME_NAME "forefire_${PROJECT_VERSION}") + # Optionally, customize DMG settings (background, icon, etc.) + endif() -include(CPack) + include(CPack) +endif() # ---------------------------------- # LFS Data Integrity Check # ---------------------------------- -message(STATUS "Running Git LFS data integrity check...") - -execute_process( - COMMAND bash tools/check_all_lfs.bash - WORKING_DIRECTORY ${CMAKE_SOURCE_DIR} - RESULT_VARIABLE LFS_CHECK_RESULT - OUTPUT_VARIABLE LFS_CHECK_OUTPUT - ERROR_VARIABLE LFS_CHECK_ERROR -) - -if(LFS_CHECK_RESULT EQUAL 0) - message(STATUS "✅ LFS data check passed successfully.") -else() - message(WARNING " +if(FOREFIRE_CHECK_LFS) + message(STATUS "Running Git LFS data integrity check...") + + execute_process( + COMMAND bash tools/check_all_lfs.bash + WORKING_DIRECTORY ${CMAKE_SOURCE_DIR} + RESULT_VARIABLE LFS_CHECK_RESULT + OUTPUT_VARIABLE LFS_CHECK_OUTPUT + ERROR_VARIABLE LFS_CHECK_ERROR + ) + + if(LFS_CHECK_RESULT EQUAL 0) + message(STATUS "✅ LFS data check passed successfully.") + else() + message(WARNING " ************************************************************ ⚠️ WARNING: Git LFS data integrity check failed! You will NOT be able to run built-in examples until LFS data @@ -270,4 +361,5 @@ ${LFS_CHECK_OUTPUT} ${LFS_CHECK_ERROR} ************************************************************ ") -endif() \ No newline at end of file + endif() +endif() diff --git a/Dockerfile b/Dockerfile index 640f8c12..e7cf2955 100644 --- a/Dockerfile +++ b/Dockerfile @@ -23,20 +23,25 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ WORKDIR /build # LICENSE is required by CPack at configure time (CMakeLists includes CPack). -COPY --link CMakeLists.txt LICENSE ./ +COPY --link CMakeLists.txt LICENSE pyproject.toml ./ COPY --link app/ ./app/ # tools/ is needed at configure time: CMakeLists.txt invokes # tools/check_all_lfs.bash and references tools/runANN/ANNTest.cpp. COPY --link tools/ ./tools/ COPY --link src/ ./src/ -RUN cmake -S . -B build -DCMAKE_BUILD_TYPE=Release \ +# -march=native is off: this image is published and has to run on CPUs other +# than the builder's. +RUN cmake -S . -B build -DCMAKE_BUILD_TYPE=Release -DFOREFIRE_NATIVE_ARCH=OFF \ && cmake --build build -j"$(nproc)" COPY --link bindings/ ./bindings/ +# The wheel is built from the repository root, where pyproject.toml drives +# CMake through scikit-build-core. It carries both the pyforefire module and +# its own copy of the `forefire` interpreter. RUN --mount=type=cache,target=/root/.cache/pip \ - pip3 wheel --no-deps --wheel-dir /wheels ./bindings/python + pip3 wheel --no-deps --wheel-dir /wheels . # Stage 2: runtime @@ -65,7 +70,8 @@ RUN --mount=type=cache,target=/root/.cache/pip \ pip3 install --break-system-packages \ lxml xarray netCDF4 -COPY --from=builder --link /build/bin/forefire /usr/local/bin/forefire +# /usr/local/bin/forefire is not copied from the builder: installing the wheel +# below provides it, and one interpreter binary beats two that can drift. COPY --from=builder --link /build/lib/libforefireL.so /usr/local/lib/ COPY --from=builder --link /wheels/ /tmp/wheels/ diff --git a/README.md b/README.md index c84db4a7..bba77256 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,37 @@ * **Applications:** Research, case reanalysis, ensemble forecasting. +## Quick Start with pip + +On Linux and macOS, ForeFire ships as a self-contained wheel. Nothing else to +install — NetCDF is bundled inside the package: + +```bash +pip install forefire +``` + +This gives you both the `forefire` command-line interpreter and the +`pyforefire` Python module: + +```bash +forefire -v +``` + +```python +import pyforefire as forefire + +ff = forefire.ForeFire() +ff.execute("FireDomain[sw=(0,0,0);ne=(10000,10000,0);t=0]") +ff.addLayer("propagation", "Iso", "propagationModel") +ff.execute("startFire[loc=(5000,5000,0.0)]") +ff.execute("step[dt=1000]") +print(ff.execute("print[]")) +``` + +Published wheels are built without MPI support. For fire-atmosphere coupling +with MesoNH, or to tune the build for your CPU, build from source instead +(see [Build from source](#build-from-source)). + ## Quick Start with Docker @@ -114,8 +145,25 @@ The demo datasets bundled under `tests/runff/` are stored with Git LFS because t See the Full Documentation for more details on building from source with the `install-forefire.sh` file +The CMake build is option-driven. The defaults below are what a plain +`cmake -S . -B build` gives you: + +| Option | Default | Purpose | +| --- | --- | --- | +| `FOREFIRE_ENABLE_MPI` | `ON` | Enable MPI coupling when MPI is available. | +| `FOREFIRE_NATIVE_ARCH` | `ON` | Compile with `-march=native`. Turn off for binaries that must run on other machines. | +| `FOREFIRE_BUILD_PYTHON` | `OFF` | Build the `pyforefire` extension module. | +| `FOREFIRE_STATIC_CORE` | `OFF` | Build the core as a static library instead of `libforefireL`. | +| `FOREFIRE_BUILD_TOOLS` | `ON` | Build the `ANN_test` helper executable. | +| `FOREFIRE_CHECK_LFS` | `ON` | Run the Git LFS integrity check while configuring. | + +Wheel builds (anything driven by `pip`) flip these to the portable defaults: +no MPI, no `-march=native`, static core, Python module on. + ## Python Bindings -ForeFire provides Python bindings for easier scripting and integration. See the Python Bindings [./bindings/python/README.md](./bindings/python/README.md) for details. +ForeFire provides Python bindings for easier scripting and integration: +`pip install forefire`, then `import pyforefire`. See the Python Bindings +[./bindings/python/README.md](./bindings/python/README.md) for details. ## Contributing diff --git a/bindings/python/README.md b/bindings/python/README.md index 64c5e8cc..6b5a04d2 100644 --- a/bindings/python/README.md +++ b/bindings/python/README.md @@ -1,95 +1,99 @@ # PyForeFire

- PyForeFire Logo + PyForeFire Logo

+**PyForeFire** provides Python bindings for [ForeFire](https://github.com/forefireAPI/forefire), +an open-source wildfire simulation engine written in C++ and developed by CNRS +at the Université de Corse Pascal Paoli. -**PyForeFire** provides Python bindings for the ForeFire library, enabling users to access ForeFire’s functionality directly from Python. The bindings link against the precompiled ForeFire library and include support for NetCDF and other dependencies. +The distribution is named `forefire` on PyPI; the importable module is +`pyforefire`. -## Overview +--- -The PyForeFire package is designed to: -- Expose core ForeFire functionality to Python via pybind11. -- Link against a precompiled ForeFire library located in the top-level `lib/` directory. -- Optionally incorporate NetCDF support by detecting either a static NetCDF installation (via `SRC_MESONH` and `XYZ`) or a dynamic installation via `NETCDF_HOME`. -- Provide helper functions that leverage additional packages such as NumPy and Matplotlib. +## Installation ---- +```bash +pip install forefire +``` -## Requirements +Wheels are published for Linux (x86_64, aarch64) and macOS (Apple Silicon and +Intel), on CPython 3.9 and newer. They are self-contained: NetCDF and its own +dependencies are bundled inside the wheel, so there is nothing to install +beforehand and nothing to configure. -Before installation, ensure that: -- The ForeFire library is precompiled and the dynamic library (e.g., `libforefireL.dylib`, `libforefireL.so`, or `libforefireL.dll`) is located in the top-level `lib/` directory. -- The ForeFire source (headers) is available in the top-level `src/` directory. -- A compatible NetCDF installation is available. Either: - - Set `SRC_MESONH` and `XYZ` to enable auto-detection of a static NetCDF installation, or - - Set `NETCDF_HOME` to the path where NetCDF (and its headers) is installed. -- Python (>=3.8) and a C++17 compiler are installed. -- The following Python packages are required: - - pybind11 - - numpy - - matplotlib +Installing also puts the `forefire` command-line interpreter on your `PATH`: ---- +```bash +forefire -v +``` -## Installation +### What the published wheels do not include -You can build and install the package into your current Python interpreter in editable mode. This mode allows you to recompile the extension without needing to reinstall the package. +Wheels are built for portability, which means they deliberately leave out two +build-time features: -1. **Editable Installation** +- **MPI coupling** is disabled, so wheels cannot drive coupled fire-atmosphere + runs with MesoNH. +- **CPU-specific optimisation** (`-march=native`) is off, so the binary runs on + any machine of the same architecture rather than only on the build machine. - In the `bindings/python` directory, run: +If you need either, build from source (below). - ```bash - pip install -e . - ``` +### Building from source - Editable mode links the installed package to the source directory. Any recompilation (e.g., via `python setup.py build_ext --inplace`) will be immediately available. +Any platform without a published wheel — Windows, musl-based Linux, or an +unusual architecture — falls back to compiling the sdist, which needs a C++ +compiler, CMake ≥ 3.15, and the NetCDF C and legacy C++4 libraries: -2. **Wheel Build** +```bash +# Debian/Ubuntu +sudo apt install build-essential cmake libnetcdf-dev libnetcdf-c++4-dev +# Fedora/RHEL +sudo dnf install gcc-c++ cmake netcdf-devel netcdf-cxx4-devel +# macOS +brew install cmake netcdf netcdf-cxx - To build a wheel without installing it directly, run: +pip install forefire --no-binary forefire +``` - ```bash - pip wheel . - ``` +To build with MPI support and native optimisation, pass the CMake options +through: - You can later install the generated wheel with: +```bash +pip install forefire --no-binary forefire \ + --config-settings=cmake.define.FOREFIRE_ENABLE_MPI=ON \ + --config-settings=cmake.define.FOREFIRE_NATIVE_ARCH=ON +``` - ```bash - pip install - ``` +If NetCDF lives somewhere CMake does not look, point at it with +`--config-settings=cmake.define.NETCDF_HOME=/path/to/netcdf` (and +`NETCDF_CXX_HOME` if the C++4 API is installed separately). --- ## Usage -### Verifying the Installation - -The first step is to confirm that the PyForeFire library was installed and linked correctly. The following code creates a `ForeFire` instance and defines a simulation domain. +### Verifying the installation ```python import pyforefire as forefire -# Create an instance of the ForeFire class ff = forefire.ForeFire() - -# Example usage: define a domain command -sizeX = 300 -sizeY = 200 -myCmd = "FireDomain[sw=(0.,0.,0.);ne=(%f,%f,0.);t=0.]" % (sizeX, sizeY) - -# Execute the command -ff.execute(myCmd) +ff.execute("FireDomain[sw=(0.,0.,0.);ne=(300.,200.,0.);t=0.]") print("PyForeFire installed and domain created successfully.") ``` -If this script runs without an `ImportError` or linking error, your installation is working. *Note: You may see warnings about missing fuel tables, which is expected at this stage.* +If this runs without an `ImportError` or linking error, your installation is +working. *Note: you may see warnings about missing fuel tables, which is +expected at this stage.* -### Running a Simple Simulation +### Running a simple simulation -To see a simulation in action, this example starts a fire in the center of a domain and runs it for 1000 seconds. +This example starts a fire in the centre of a domain and runs it for 1000 +seconds. ```python import pyforefire as forefire @@ -100,7 +104,7 @@ ff = forefire.ForeFire() sim_shape = (10000, 10000) ff.execute(f'FireDomain[sw=(0,0,0);ne=({sim_shape[0]},{sim_shape[1]},0);t=0]') -# 2. Set a simple propagation model (isotropic, i.e., a perfect circle) +# 2. Set a simple propagation model (isotropic, i.e. a perfect circle) ff.addLayer("propagation", "Iso", "propagationModel") # 3. Start a fire in the center of the domain @@ -113,52 +117,87 @@ ff.execute("step[dt=1000]") print(ff.execute("print[]")) ``` -This will produce text output describing the location of the fire front nodes. +This produces text output describing the location of the fire front nodes. + +To generate a `circle.kml` file for visualization in Google Earth, set the +`dumpMode` parameter before the final print command: -To generate a `circle.kml` file for visualization in Google Earth, you can set the `dumpMode` parameter before the final print command: ```python -# Optional: Set output mode to KML and save to a file ff["dumpMode"] = "kml" ff.execute("print[circle.kml]") ``` -### More Advanced Examples +### More advanced examples -For more complex examples that use real-world data (like fuel, topography, and wind), please see the scripts located in the `tests/python/` directory of the main repository. +For examples that use real-world data (fuel, topography, wind), see the scripts +in the [`tests/python/`](https://github.com/forefireAPI/forefire/tree/master/tests/python) +directory of the main repository. --- ## Development -For development or when modifying the underlying C++ code: -- Rebuild the Python extension module in-place using: +The Python bindings are built from the repository root, together with the C++ +core: - ```bash - python setup.py build_ext --inplace - ``` +```bash +git clone https://github.com/forefireAPI/forefire.git +cd forefire +pip install -e . +``` + +Re-run that command after touching `_pyforefire.cpp` or the C++ core. If you +iterate often, install the build requirements once and let scikit-build-core +recompile on import instead: -- This approach updates the extension module immediately without the need for a full reinstall. +```bash +pip install scikit-build-core pybind11 +pip install -e . --no-build-isolation --config-settings=editable.rebuild=true +``` +To build a wheel without installing it: -## Troubleshooting +```bash +pip wheel . -w dist/ +``` + +The build is driven by [scikit-build-core](https://scikit-build-core.readthedocs.io/), +configured in the root `pyproject.toml`; the extension module target itself +lives in the root `CMakeLists.txt` behind `FOREFIRE_BUILD_PYTHON`. + +### Smoke testing a built wheel -- **NetCDF Not Found:** - If you encounter an error such as `fatal error: 'netcdf' file not found`, ensure that either: - - Your environment variables `SRC_MESONH` and `XYZ` (for static linking) or `NETCDF_HOME` (for dynamic linking) are correctly set, or - - Your system includes the necessary NetCDF headers and libraries in the default search paths. +```bash +python tests/python/test_wheel.py +``` + +Run this against an installed wheel rather than from a build tree: it checks +that the extension loads, that its bundled NetCDF resolves, and that a trivial +simulation advances. + +--- + +## Troubleshooting -- **Editable Installation Issues:** - Ensure you are running the command in the correct directory (`bindings/python`) and that no legacy configuration files (like a `setup.cfg`) conflict with the `pyproject.toml`. +- **`NetCDF not found` while building from source:** install the *two* NetCDF + packages listed above. The C library alone is not enough; ForeFire's + `DataBroker` includes the legacy C++4 header ``, which ships in + `libnetcdf-c++4-dev` / `netcdf-cxx4-devel` / `netcdf-cxx`. +- **`Illegal instruction` after copying a self-built install to another + machine:** it was compiled with `-march=native`. Rebuild with + `FOREFIRE_NATIVE_ARCH=OFF`, or use the published wheel. --- ## License -This project is licensed under the terms specified in the `LICENSE` file. +ForeFire is licensed under the GNU General Public License v3.0. See the +[LICENSE](https://github.com/forefireAPI/forefire/blob/master/LICENSE) file. --- ## Project URLs -- **Homepage:** [https://github.com/forefireAPI/forefire](https://github.com/forefireAPI/forefire) -- **Documentation:** [https://forefire.readthedocs.io/en/latest/](https://forefire.readthedocs.io/en/latest/) \ No newline at end of file +- **Homepage:** [https://forefire.univ-corse.fr/](https://forefire.univ-corse.fr/) +- **Repository:** [https://github.com/forefireAPI/forefire](https://github.com/forefireAPI/forefire) +- **Documentation:** [https://forefire.readthedocs.io/en/latest/](https://forefire.readthedocs.io/en/latest/) diff --git a/bindings/python/pyproject.toml b/bindings/python/pyproject.toml deleted file mode 100644 index 84990df3..00000000 --- a/bindings/python/pyproject.toml +++ /dev/null @@ -1,33 +0,0 @@ -[project] -name = "pyforefire" -version = "2025.2" -dependencies = [ - "pybind11", - "numpy", - "matplotlib" -] -requires-python = ">=3.8" -authors = [ - { name = "Jean-Baptiste Filippi", email = "filippi_j@univ-corse.fr" }, - { name = "Quentin Duriani", email = "qduriani@gmail.com" } -] -maintainers = [ - { name = "Jean-Baptiste Filippi", email = "filippi_j@univ-corse.fr" } -] -description = "Python version of ForeFire library" -readme = { file = "README.md", content-type = "text/markdown" } -license = { file = "LICENSE" } -keywords = ["ForeFire", "forefire", "pyforefire", "pybind", "pybind11"] -classifiers = [ - "Programming Language :: Python :: 3", - "Programming Language :: C++", - "Operating System :: OS Independent" -] - -[build-system] -requires = ["setuptools>=61", "wheel", "pybind11"] -build-backend = "setuptools.build_meta" - -[project.urls] -Homepage = "https://github.com/forefireAPI/forefire" -Repository = "https://github.com/forefireAPI/forefire" diff --git a/bindings/python/setup.py b/bindings/python/setup.py deleted file mode 100644 index 364f3576..00000000 --- a/bindings/python/setup.py +++ /dev/null @@ -1,111 +0,0 @@ -import os -import platform -import glob -from setuptools import setup, find_packages -from pybind11.setup_helpers import Pybind11Extension, build_ext - -# Determine current directory (where setup.py resides) -this_dir = os.path.abspath(os.path.dirname(__file__)) -# Compute the top-level ForeFire directory (two levels up: bindings/python -> bindings -> forefire) -FOREFIRE_DIR = os.path.abspath(os.path.join(this_dir, "..", "..")) -FOREFIRE_LIB = "forefireL" # as used in lib/libforefireL.dylib (or .so/.dll) - -# Determine platform-specific library extension. -current_platform = platform.system() -if current_platform == "Darwin": - lib_ext = "dylib" -elif current_platform == "Linux": - lib_ext = "so" -elif current_platform == "Windows": - lib_ext = "dll" -else: - raise RuntimeError("Unsupported platform: " + current_platform) - -# ForeFire precompiled library directory. -forefire_lib_dir = os.path.join(FOREFIRE_DIR, "lib") - -# --- Determine NetCDF configuration --- -# Initialize lists for additional include and library directories, and libraries to link. -netcdf_include_dirs = [] -netcdf_library_dirs = [] -netcdf_libraries = [] - -# Option 1: Use MESONH and XYZ to auto-detect a static NetCDF installation. -if os.environ.get("SRC_MESONH") and os.environ.get("XYZ"): - mesonh = os.environ["SRC_MESONH"] - xyz = os.environ["XYZ"] - pattern = os.path.join(mesonh, "src", "dir_obj" + xyz, "MASTER", "NETCDF-*") - netcdf_dirs = glob.glob(pattern) - if netcdf_dirs: - NETCDF_HOME = netcdf_dirs[0] - print("Detected NETCDF_HOME:", NETCDF_HOME) - # (In your CMake you use the lib64 directory for static linking.) - netcdf_include_dirs.append(os.path.join(NETCDF_HOME, "include")) - netcdf_library_dirs.append(os.path.join(NETCDF_HOME, "lib64")) - # For static linking you might want to add extra_objects (see below). - # Here we assume dynamic linking for simplicity: - netcdf_libraries.extend(["netcdf", "netcdf_c++4"]) - else: - raise RuntimeError(f"No NETCDF-* directory found under {mesonh}/src/dir_obj{xyz}/MASTER/") -# Option 2: Use NETCDF_HOME if defined. -elif os.environ.get("NETCDF_HOME"): - NETCDF_HOME = os.environ["NETCDF_HOME"] - print("NETCDF_HOME set to:", NETCDF_HOME) - netcdf_include_dirs.append(os.path.join(NETCDF_HOME, "include")) - netcdf_library_dirs.append(os.path.join(NETCDF_HOME, "lib")) - netcdf_libraries.extend(["netcdf", "netcdf_c++4"]) -else: - print("Warning: NETCDF_HOME not set. " - "Attempting to use system default include and library paths for NetCDF.") - -# --- Define the Pybind11 extension --- -ext_modules = [ - Pybind11Extension( - "pyforefire._pyforefire", - ["src/pyforefire/_pyforefire.cpp"], - include_dirs=[ - os.path.join(FOREFIRE_DIR, "src"), - os.path.join(FOREFIRE_DIR, "src", "include") - ] + netcdf_include_dirs, - libraries=[FOREFIRE_LIB] + netcdf_libraries, - library_dirs=[forefire_lib_dir] + netcdf_library_dirs, - # On non-Windows platforms, specify runtime library directories so the dynamic linker finds the libs. - runtime_library_dirs=([forefire_lib_dir] + netcdf_library_dirs) if current_platform != "Windows" else [], - # Link the precompiled ForeFire library explicitly. - extra_objects=[os.path.join(forefire_lib_dir, f"lib{FOREFIRE_LIB}.{lib_ext}")], - # not needed up to 2025 extra_compile_args=["-std=c++17"], - language="c++" - ) -] - -with open(os.path.join(this_dir, "README.md"), "r", encoding="utf-8") as fh: - long_description = fh.read() - -setup( - name="pyforefire", - version="2025.2", - install_requires=[ - "pybind11", - "setuptools", - "wheel", - "numpy", - "matplotlib", - ], - author="Jean-Baptiste Filippi", - author_email="filippi_j@univ-corse.fr", - description="Python bindings for ForeFire library", - long_description=long_description, - long_description_content_type="text/markdown", - ext_modules=ext_modules, - cmdclass={"build_ext": build_ext}, - packages=find_packages(where="src"), - package_dir={"": "src"}, - include_package_data=True, - zip_safe=False, - classifiers=[ - "Programming Language :: Python :: 3", - "Programming Language :: C++", - "Operating System :: OS Independent" - ], - python_requires=">=3.8", -) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..0a143e39 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,101 @@ +[build-system] +requires = ["scikit-build-core>=0.11", "pybind11>=2.12"] +build-backend = "scikit_build_core.build" + +[project] +name = "forefire" +dynamic = ["version"] +description = "ForeFire wildfire simulation engine, with Python bindings" +readme = "bindings/python/README.md" +requires-python = ">=3.9" +license = "GPL-3.0-or-later" +license-files = ["LICENSE"] +authors = [ + { name = "Jean-Baptiste Filippi", email = "filippi_j@univ-corse.fr" }, + { name = "Quentin Duriani", email = "qduriani@gmail.com" }, +] +maintainers = [ + { name = "Jean-Baptiste Filippi", email = "filippi_j@univ-corse.fr" }, +] +keywords = ["ForeFire", "forefire", "pyforefire", "wildfire", "fire", "simulation", "wildfire-simulation"] +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Intended Audience :: Science/Research", + "Programming Language :: C++", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: Scientific/Engineering", + "Operating System :: POSIX :: Linux", + "Operating System :: MacOS :: MacOS X", +] +dependencies = [ + "numpy", + "matplotlib", +] + +[project.urls] +Homepage = "https://forefire.univ-corse.fr/" +Documentation = "https://forefire.readthedocs.io/en/latest/" +Repository = "https://github.com/forefireAPI/forefire" +Issues = "https://github.com/forefireAPI/forefire/issues" + +[tool.scikit-build] +minimum-version = "build-system.requires" +# 3.18 for FindPython's Development.Module component; plain C++ builds of +# ForeFire still work with the 3.15 declared in CMakeLists.txt. +cmake.version = ">=3.18" +build-dir = "build/{wheel_tag}" + +# The importable package keeps its historical name, `pyforefire`; only the +# distribution on PyPI is called `forefire`. +wheel.packages = ["bindings/python/src/pyforefire"] +wheel.exclude = ["pyforefire/_pyforefire.cpp", "pyforefire/_pyforefire.h"] + +# The test suite carries ~60 MB of Git LFS fixtures, and the docs/paper are of +# no use to someone building from an sdist. The wheel smoke test is small and +# needs no fixtures, so it is kept. +sdist.exclude = [ + ".github", + "docs", + "paper", + "tests", + "tools/cache", + "tools/htdocs", + "*.svg", +] +sdist.include = ["tests/python/test_wheel.py"] + +[tool.scikit-build.metadata.version] +provider = "scikit_build_core.metadata.regex" +input = "src/include/Version.h" +regex = '''ff_version\s*=\s*"v?(?P\d+\.\d+\.\d+)"''' + +[tool.cibuildwheel] +# 32-bit and musl targets have no usable NetCDF C++4 story, and PyPy cannot +# load the pybind11 module built here. +skip = ["pp*", "*-musllinux_*", "*_i686", "*-win32"] +test-command = [ + "python {project}/tests/python/test_wheel.py", + "forefire -v", +] + +[tool.cibuildwheel.linux] +# manylinux_2_28 is the oldest image where EPEL ships netcdf/hdf5 for both +# x86_64 and aarch64; the C++4 API is built from source by the script below. +manylinux-x86_64-image = "manylinux_2_28" +manylinux-aarch64-image = "manylinux_2_28" +before-all = "bash {project}/tools/devops/install-netcdf-manylinux.sh" + +[tool.cibuildwheel.macos] +before-all = "brew install netcdf netcdf-cxx" + +[tool.cibuildwheel.macos.environment] +# Homebrew is not on the default compiler search path on Apple Silicon. +CMAKE_PREFIX_PATH = "/opt/homebrew:/usr/local" +# Must not be older than the Homebrew bottles we link against, otherwise the +# linker rejects the NetCDF dylibs as "built for a newer macOS version". +MACOSX_DEPLOYMENT_TARGET = "13.0" diff --git a/tests/python/test_wheel.py b/tests/python/test_wheel.py new file mode 100644 index 00000000..e82bf750 --- /dev/null +++ b/tests/python/test_wheel.py @@ -0,0 +1,96 @@ +"""Smoke test for the built `forefire` wheel. + +Run against an *installed* wheel (never from the source tree), so it only +touches things a user gets from `pip install forefire`: the extension module +loads, its vendored NetCDF dependency resolves, and a trivial simulation +actually advances. + + python tests/python/test_wheel.py +""" + +import contextlib +import os +import sys +import tempfile + + +class CapturedOutput: + """Text the C++ side wrote to stdout, which `sys.stdout` never sees. + + ForeFire reports unknown models on `std::cout`, so the capture has to + happen at the file-descriptor level. + """ + + def __init__(self): + self.text = "" + + +@contextlib.contextmanager +def captured_native_stdout(): + captured = CapturedOutput() + with tempfile.TemporaryFile(mode="w+") as tmp: + sys.stdout.flush() + saved = os.dup(1) + try: + os.dup2(tmp.fileno(), 1) + yield captured + finally: + os.dup2(saved, 1) + os.close(saved) + tmp.seek(0) + captured.text = tmp.read() + + +def test_import(): + import pyforefire + + print("pyforefire imported from", pyforefire.__file__) + assert hasattr(pyforefire, "ForeFire"), "ForeFire class missing from the module" + return pyforefire + + +def test_simulation(pyforefire): + """Burn an isotropic circle and check the front actually grew.""" + ff = pyforefire.ForeFire() + size = 10000 + + ff.execute(f"FireDomain[sw=(0,0,0);ne=({size},{size},0);t=0]") + + # Propagation models register themselves from static initialisers, so a + # build that drops those objects leaves the model table empty and the + # simulation segfaults a few calls later. Fail here instead, with a + # readable message. + with captured_native_stdout() as captured: + ff.addLayer("propagation", "Iso", "propagationModel") + assert "not recognized" not in captured.text, ( + "propagation model 'Iso' is not registered — the core was probably " + f"linked in a way that discards the model objects:\n{captured.text}" + ) + + ff.execute(f"startFire[loc=({size / 2},{size / 2},0.0)]") + ff.execute("step[dt=1000]") + + front = ff.execute("print[]") + assert "FireNode" in front, f"no fire nodes after 1000 s of simulation:\n{front}" + print(f"simulation produced {front.count('FireNode')} fire nodes") + + +def test_helpers(): + """`pyforefire.helpers` pulls in numpy/matplotlib, which are hard deps.""" + from pyforefire import helpers + + table = helpers.get_fuels_table("Rothermel")() + assert table.startswith("Index;Rhod"), f"unexpected fuel table header: {table[:40]!r}" + print(f"fuel table loaded with {table.count(chr(10))} rows") + + +def main(): + pyforefire = test_import() + test_simulation(pyforefire) + test_helpers() + print("\nOK: forefire wheel smoke test passed") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/devops/install-netcdf-manylinux.sh b/tools/devops/install-netcdf-manylinux.sh new file mode 100644 index 00000000..f657327b --- /dev/null +++ b/tools/devops/install-netcdf-manylinux.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +# +# Install the NetCDF stack inside a manylinux_2_28 container, for cibuildwheel. +# +# EPEL provides the NetCDF C library and HDF5 for both x86_64 and aarch64, but +# no package for the legacy C++4 API that ForeFire's DataBroker includes as +# , so that one is built from source. Everything lands in /usr/local +# where auditwheel can find it and vendor it into the wheel. +set -euo pipefail + +NETCDF_CXX4_VERSION="${NETCDF_CXX4_VERSION:-4.3.1}" +NETCDF_CXX4_SHA256="6a1189a181eed043b5859e15d5c080c30d0e107406fbb212c8fb9814e90f3445" +PREFIX="${PREFIX:-/usr/local}" + +if [[ -f "${PREFIX}/include/netcdf" ]]; then + echo "NetCDF C++4 already installed in ${PREFIX}, nothing to do." + exit 0 +fi + +echo "::group::Installing NetCDF C library from EPEL" +dnf install -y epel-release +dnf install -y netcdf-devel hdf5-devel +echo "::endgroup::" + +echo "::group::Building netcdf-cxx4 ${NETCDF_CXX4_VERSION}" +workdir="$(mktemp -d)" +trap 'rm -rf "${workdir}"' EXIT +cd "${workdir}" + +tarball="netcdf-cxx4-${NETCDF_CXX4_VERSION}.tar.gz" +curl -fsSL -o "${tarball}" \ + "https://downloads.unidata.ucar.edu/netcdf-cxx/${NETCDF_CXX4_VERSION}/${tarball}" +echo "${NETCDF_CXX4_SHA256} ${tarball}" | sha256sum --check --strict + +tar xzf "${tarball}" +cd "netcdf-cxx4-${NETCDF_CXX4_VERSION}" + +# Autotools rather than the bundled CMake build: netcdf-cxx4 4.3.1 declares +# `cmake_minimum_required(VERSION 2.8.12)`, and CMake 4 refuses anything below +# 3.5. configure picks up the EPEL NetCDF through nc-config. +./configure \ + --prefix="${PREFIX}" \ + --enable-shared \ + --disable-static +make -j "$(nproc)" +make install +ldconfig +echo "::endgroup::" + +echo "NetCDF C++4 installed:" +ls -l "${PREFIX}"/lib*/libnetcdf_c++4.so* "${PREFIX}/include/netcdf" From eeb09f2b31f9bbb19d3b8e525296e72b9984b873 Mon Sep 17 00:00:00 2001 From: HugoFara Date: Wed, 29 Jul 2026 00:41:55 +0200 Subject: [PATCH 2/2] packaging: drop the Development Status classifier Project maturity is a maintainer's call, not something packaging should assert. Left unset rather than guessed. --- pyproject.toml | 1 - 1 file changed, 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 0a143e39..b43f46f4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,7 +19,6 @@ maintainers = [ ] keywords = ["ForeFire", "forefire", "pyforefire", "wildfire", "fire", "simulation", "wildfire-simulation"] classifiers = [ - "Development Status :: 5 - Production/Stable", "Intended Audience :: Science/Research", "Programming Language :: C++", "Programming Language :: Python :: 3",