From 038fbcd2129ff93b1712cfcee75f3951b0121a73 Mon Sep 17 00:00:00 2001 From: said Date: Thu, 10 Sep 2026 07:17:16 +0100 Subject: [PATCH 01/15] codex: add CMake integration --- CHANGELOG.md | 5 + MANIFEST.in | 1 + cmake/UsePRIK.cmake | 345 ++++++++++ docs/user/guide/cmake.md | 110 +++ docs/user/guide/index.md | 4 +- docs/user/reference/cli-commands.md | 15 +- docs/user/reference/index.md | 2 + mkdocs.yml | 1 + prik/cli.py | 59 +- prik/cmake.py | 305 +++++++++ prik/compiler/native_support.py | 13 +- prik/pipeline/build.py | 67 +- pyproject.toml | 3 + .../building/end_to_end/test_cmake_builds.py | 647 ++++++++++++++++++ .../cli/pipeline/test_argument_contract.py | 14 +- 15 files changed, 1559 insertions(+), 32 deletions(-) create mode 100644 cmake/UsePRIK.cmake create mode 100644 docs/user/guide/cmake.md create mode 100644 prik/cmake.py create mode 100644 tests/fortran/infrastructure/building/end_to_end/test_cmake_builds.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 7fe984e11..9606ff6e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ release tags add a leading `v` to the package version. ## Unreleased +- Added CMake integration through the packaged `UsePRIK.cmake` helper and a + `prik generate --cmake` standalone-project mode. CMake generates PRIK wrapper + sources as build outputs and owns native compilation, linking, external + targets, and incremental rebuilds. + - Array handles support allocatable and pointer arguments, results, module variables, derived fields, optional arguments, and matching ordinary-array parameters. Numeric and character arrays accept supported forward and diff --git a/MANIFEST.in b/MANIFEST.in index f96ea7399..6792ee98f 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,3 +1,4 @@ include CHANGELOG.md include CITATION.cff include .artifacts/.gitignore +recursive-include cmake *.cmake diff --git a/cmake/UsePRIK.cmake b/cmake/UsePRIK.cmake new file mode 100644 index 000000000..685b54cf3 --- /dev/null +++ b/cmake/UsePRIK.cmake @@ -0,0 +1,345 @@ +#[=======================================================================[.rst: + +UsePRIK +------- + +Create a Python extension whose wrapper sources are generated by PRIK and +whose native compilation and linking are owned by CMake. + +]=======================================================================] + +include_guard(GLOBAL) +include(CMakeParseArguments) + +if(NOT Python_EXECUTABLE OR NOT COMMAND Python_add_library) + find_package(Python COMPONENTS Interpreter Development.Module REQUIRED) +endif() + +function(_prik_make_absolute_paths output_variable) + set(_absolute_paths) + foreach(_path IN LISTS ARGN) + if(IS_ABSOLUTE "${_path}") + list(APPEND _absolute_paths "${_path}") + else() + get_filename_component(_absolute_path "${_path}" ABSOLUTE BASE_DIR "${CMAKE_CURRENT_SOURCE_DIR}") + list(APPEND _absolute_paths "${_absolute_path}") + endif() + endforeach() + set(${output_variable} "${_absolute_paths}" PARENT_SCOPE) +endfunction() + +function(_prik_validate_source_suffixes language sources) + foreach(_source IN LISTS ${sources}) + get_filename_component(_suffix "${_source}" LAST_EXT) + string(TOLOWER "${_suffix}" _suffix) + if(language STREQUAL "fortran") + if(NOT _suffix MATCHES "^\\.(f|f03|f08|f77|f90|f95|for|ftn)$") + message(FATAL_ERROR "PRIK Fortran input is not a supported source: ${_source}") + endif() + elseif(NOT _suffix STREQUAL ".c") + message(FATAL_ERROR "PRIK C input is not a supported source: ${_source}") + endif() + endforeach() +endfunction() + +function(_prik_append_cli_flags command option flags) + set(_command ${${command}}) + foreach(_flag IN LISTS ${flags}) + list(APPEND _command "${option}=${_flag}") + endforeach() + set(${command} "${_command}" PARENT_SCOPE) +endfunction() + +function(_prik_json_string_list output_variable json key) + string(JSON _prik_item_count ERROR_VARIABLE _prik_json_error LENGTH "${json}" "${key}") + if(_prik_json_error) + message(FATAL_ERROR "PRIK did not return ${key}: ${_prik_json_error}") + endif() + + set(_prik_items) + if(_prik_item_count GREATER 0) + math(EXPR _prik_last_item_index "${_prik_item_count} - 1") + foreach(_prik_item_index RANGE 0 ${_prik_last_item_index}) + string(JSON _prik_item GET "${json}" "${key}" ${_prik_item_index}) + list(APPEND _prik_items "${_prik_item}") + endforeach() + endif() + set(${output_variable} "${_prik_items}" PARENT_SCOPE) +endfunction() + +function(_prik_rebase_generated_paths output_variable paths source_root output_root) + set(_prik_rebased_paths) + foreach(_prik_path IN LISTS ${paths}) + file(RELATIVE_PATH _prik_relative_path "${source_root}" "${_prik_path}") + if(_prik_relative_path MATCHES "^\\.\\.") + message(FATAL_ERROR "PRIK generated output is outside its output directory: ${_prik_path}") + endif() + list(APPEND _prik_rebased_paths "${output_root}/${_prik_relative_path}") + endforeach() + set(${output_variable} "${_prik_rebased_paths}" PARENT_SCOPE) +endfunction() + +function(_prik_validate_args args) + foreach(_prik_arg IN LISTS ${args}) + if(_prik_arg MATCHES "^--(build-manifest|cmake|json|language|makefile|module-name|native-c-sources|native-fortran-sources|no-compile-input-sources|out|out-dir|plan|pyi|sources)(=|$)") + message(FATAL_ERROR "PRIK_ARGS cannot override prik_add_module build ownership: ${_prik_arg}") + endif() + endforeach() +endfunction() + +function(prik_add_module name) + if(NOT name MATCHES "^[A-Za-z_][A-Za-z0-9_]*$") + message(FATAL_ERROR "PRIK module name must be a Python/CMake identifier: ${name}") + endif() + if(TARGET "${name}") + message(FATAL_ERROR "PRIK module target already exists: ${name}") + endif() + + set(_options NO_COMPILE_INPUT_SOURCES) + set(_one_value_arguments CONTRACT) + set(_multi_value_arguments + SOURCES + FORTRAN_SOURCES + C_SOURCES + INCLUDE_DIRS + MODULE_DIRS + FORTRAN_FLAGS + C_FLAGS + LINK_LIBRARIES + LINK_OPTIONS + PRIK_ARGS + ) + cmake_parse_arguments(PRIK "${_options}" "${_one_value_arguments}" "${_multi_value_arguments}" ${ARGN}) + if(PRIK_UNPARSED_ARGUMENTS) + message(FATAL_ERROR "Unknown prik_add_module arguments: ${PRIK_UNPARSED_ARGUMENTS}") + endif() + _prik_validate_args(PRIK_PRIK_ARGS) + + if(PRIK_CONTRACT AND PRIK_SOURCES) + message(FATAL_ERROR "prik_add_module(${name}) cannot combine CONTRACT and SOURCES") + endif() + if(PRIK_NO_COMPILE_INPUT_SOURCES AND NOT PRIK_SOURCES) + message(FATAL_ERROR "prik_add_module(${name}) uses NO_COMPILE_INPUT_SOURCES only with SOURCES") + endif() + if(PRIK_NO_COMPILE_INPUT_SOURCES AND NOT PRIK_FORTRAN_SOURCES AND NOT PRIK_C_SOURCES) + message(FATAL_ERROR "prik_add_module(${name}) requires native implementation sources") + endif() + if(NOT PRIK_CONTRACT AND NOT PRIK_SOURCES AND NOT PRIK_FORTRAN_SOURCES AND NOT PRIK_C_SOURCES) + message(FATAL_ERROR "prik_add_module(${name}) requires SOURCES, FORTRAN_SOURCES, C_SOURCES, or CONTRACT") + endif() + + _prik_make_absolute_paths(_prik_contract "${PRIK_CONTRACT}") + _prik_make_absolute_paths(_prik_sources ${PRIK_SOURCES}) + _prik_make_absolute_paths(_prik_fortran_sources ${PRIK_FORTRAN_SOURCES}) + _prik_make_absolute_paths(_prik_c_sources ${PRIK_C_SOURCES}) + _prik_make_absolute_paths(_prik_include_dirs ${PRIK_INCLUDE_DIRS}) + _prik_make_absolute_paths(_prik_module_dirs ${PRIK_MODULE_DIRS}) + + if(_prik_contract) + set(_prik_wrapper_sources) + set(_prik_native_fortran_sources ${_prik_fortran_sources}) + set(_prik_native_c_sources ${_prik_c_sources}) + if(_prik_native_fortran_sources) + set(_prik_language fortran) + elseif(_prik_native_c_sources) + set(_prik_language c) + else() + message(FATAL_ERROR "PRIK contract module ${name} requires native FORTRAN_SOURCES or C_SOURCES") + endif() + else() + if(_prik_sources) + set(_prik_wrapper_sources ${_prik_sources}) + set(_prik_native_fortran_sources ${_prik_fortran_sources}) + set(_prik_native_c_sources ${_prik_c_sources}) + elseif(_prik_fortran_sources AND NOT _prik_c_sources) + set(_prik_wrapper_sources ${_prik_fortran_sources}) + set(_prik_native_fortran_sources) + set(_prik_native_c_sources) + elseif(_prik_c_sources AND NOT _prik_fortran_sources) + set(_prik_wrapper_sources ${_prik_c_sources}) + set(_prik_native_fortran_sources) + set(_prik_native_c_sources) + else() + message(FATAL_ERROR "PRIK module ${name} cannot mix Fortran and C wrapper sources") + endif() + + list(GET _prik_wrapper_sources 0 _prik_first_source) + get_filename_component(_prik_first_suffix "${_prik_first_source}" LAST_EXT) + string(TOLOWER "${_prik_first_suffix}" _prik_first_suffix) + if(_prik_first_suffix STREQUAL ".c") + set(_prik_language c) + else() + set(_prik_language fortran) + endif() + endif() + + _prik_validate_source_suffixes("${_prik_language}" _prik_wrapper_sources) + _prik_validate_source_suffixes(fortran _prik_native_fortran_sources) + _prik_validate_source_suffixes(c _prik_native_c_sources) + + set(_prik_output_dir "${CMAKE_CURRENT_BINARY_DIR}/prik/${name}") + file(MAKE_DIRECTORY "${_prik_output_dir}") + + if(_prik_language STREQUAL "fortran") + set(_prik_analysis_compiler "${CMAKE_Fortran_COMPILER}") + else() + set(_prik_analysis_compiler "${CMAKE_C_COMPILER}") + endif() + if(NOT _prik_analysis_compiler) + message(FATAL_ERROR "PRIK module ${name} requires CMake to enable ${_prik_language}") + endif() + + set(_prik_generate_command "${Python_EXECUTABLE}" -m prik generate --sources) + if(_prik_contract) + list(APPEND _prik_generate_command "${_prik_contract}") + else() + list(APPEND _prik_generate_command ${_prik_wrapper_sources}) + endif() + if(PRIK_NO_COMPILE_INPUT_SOURCES) + list(APPEND _prik_generate_command --no-compile-input-sources) + endif() + list(APPEND _prik_generate_command --module-name "${name}") + list(APPEND _prik_generate_command --language "${_prik_language}") + # Later PRIK_ARGS may override this when source analysis requires a + # distinct compiler, but CMake's selected toolchain is the default. + list(APPEND _prik_generate_command --compiler "${_prik_analysis_compiler}") + _prik_append_cli_flags(_prik_generate_command --native-compile-flags PRIK_FORTRAN_FLAGS) + _prik_append_cli_flags(_prik_generate_command --native-c-compile-flags PRIK_C_FLAGS) + foreach(_include_dir IN LISTS _prik_include_dirs _prik_module_dirs) + list(APPEND _prik_generate_command -I "${_include_dir}") + endforeach() + if(_prik_native_fortran_sources) + list(APPEND _prik_generate_command --native-fortran-sources ${_prik_native_fortran_sources}) + endif() + if(_prik_native_c_sources) + list(APPEND _prik_generate_command --native-c-sources ${_prik_native_c_sources}) + endif() + list(APPEND _prik_generate_command ${PRIK_PRIK_ARGS}) + list(APPEND _prik_generate_command --json) + + # Ask PRIK's completed plan for canonical output names without materializing + # sources. The actual source generation remains the custom command below. + set(_prik_plan_dir "${CMAKE_CURRENT_BINARY_DIR}/CMakeFiles/prik-plan/${name}") + file(REMOVE_RECURSE "${_prik_plan_dir}") + set(_prik_plan_command ${_prik_generate_command} --plan --out-dir "${_prik_plan_dir}") + + execute_process( + COMMAND ${_prik_plan_command} + WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}" + RESULT_VARIABLE _prik_configure_result + OUTPUT_VARIABLE _prik_plan_json + ERROR_VARIABLE _prik_configure_error + ) + if(NOT _prik_configure_result EQUAL 0) + message(FATAL_ERROR "PRIK wrapper generation failed for ${name}:\n${_prik_configure_error}") + endif() + + _prik_json_string_list(_prik_planned_sources "${_prik_plan_json}" generated_sources) + if(NOT _prik_planned_sources) + message(FATAL_ERROR "PRIK did not return generated wrapper sources for ${name}") + endif() + _prik_json_string_list(_prik_planned_outputs "${_prik_plan_json}" generated_files) + if(NOT _prik_planned_outputs) + message(FATAL_ERROR "PRIK did not return generated outputs for ${name}") + endif() + _prik_json_string_list(_prik_semantic_dependencies "${_prik_plan_json}" sources) + + string( + JSON _prik_native_unit_count + ERROR_VARIABLE _prik_native_plan_error + LENGTH "${_prik_plan_json}" native_build_plan compilation_units + ) + if(_prik_native_plan_error) + message(FATAL_ERROR "PRIK did not return native compilation units: ${_prik_native_plan_error}") + endif() + set(_prik_native_target_sources) + if(_prik_native_unit_count GREATER 0) + math(EXPR _prik_last_native_unit_index "${_prik_native_unit_count} - 1") + foreach(_prik_native_unit_index RANGE 0 ${_prik_last_native_unit_index}) + string( + JSON _prik_native_target_source + GET "${_prik_plan_json}" native_build_plan compilation_units ${_prik_native_unit_index} source + ) + list(APPEND _prik_native_target_sources "${_prik_native_target_source}") + endforeach() + endif() + + execute_process( + COMMAND "${Python_EXECUTABLE}" -c "import numpy; print(numpy.get_include())" + RESULT_VARIABLE _prik_numpy_result + OUTPUT_VARIABLE _prik_numpy_include + ERROR_VARIABLE _prik_numpy_error + OUTPUT_STRIP_TRAILING_WHITESPACE + ) + if(NOT _prik_numpy_result EQUAL 0) + message(FATAL_ERROR "PRIK requires NumPy headers for ${name}:\n${_prik_numpy_error}") + endif() + _prik_rebase_generated_paths( + _prik_generated_sources _prik_planned_sources "${_prik_plan_dir}" "${_prik_output_dir}" + ) + _prik_rebase_generated_paths( + _prik_generation_outputs _prik_planned_outputs "${_prik_plan_dir}" "${_prik_output_dir}" + ) + + # Semantic inputs can add or remove a bridge source, so make CMake + # reconfigure before it evaluates the target source list again. + set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS ${_prik_semantic_dependencies}) + + set(_prik_command_signature_dir "${CMAKE_CURRENT_BINARY_DIR}/CMakeFiles/prik") + file(MAKE_DIRECTORY "${_prik_command_signature_dir}") + set(_prik_command_signature "${_prik_command_signature_dir}/${name}-generation-command.txt") + string(JOIN "\n" _prik_command_signature_text ${_prik_generate_command} --out-dir "${_prik_output_dir}") + file(CONFIGURE OUTPUT "${_prik_command_signature}" CONTENT "${_prik_command_signature_text}\n" @ONLY) + + set(_prik_dependencies + ${_prik_semantic_dependencies} + "${_prik_command_signature}" + "${CMAKE_CURRENT_FUNCTION_LIST_FILE}" + ) + list(FILTER _prik_dependencies EXCLUDE REGEX "^$") + set(_prik_actual_generate_command ${_prik_generate_command} --out-dir "${_prik_output_dir}") + file(REMOVE_RECURSE "${_prik_plan_dir}") + add_custom_command( + OUTPUT ${_prik_generation_outputs} + COMMAND ${_prik_actual_generate_command} + DEPENDS ${_prik_dependencies} + WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}" + VERBATIM + COMMENT "Generate PRIK wrapper sources for ${name}" + ) + + Python_add_library("${name}" MODULE WITH_SOABI ${_prik_generated_sources}) + set(_prik_additional_outputs ${_prik_generation_outputs}) + list(REMOVE_ITEM _prik_additional_outputs ${_prik_generated_sources}) + if(_prik_additional_outputs) + target_sources("${name}" PRIVATE ${_prik_additional_outputs}) + endif() + if(_prik_native_target_sources) + target_sources("${name}" PRIVATE ${_prik_native_target_sources}) + endif() + set_target_properties("${name}" PROPERTIES PREFIX "" OUTPUT_NAME "${name}") + if(_prik_language STREQUAL "fortran" OR _prik_native_fortran_sources) + set_target_properties("${name}" PROPERTIES Fortran_MODULE_DIRECTORY "${_prik_output_dir}") + endif() + + target_include_directories( + "${name}" + PRIVATE + "${_prik_output_dir}" + "${_prik_numpy_include}" + ${_prik_include_dirs} + ${_prik_module_dirs} + ) + foreach(_flag IN LISTS PRIK_FORTRAN_FLAGS) + target_compile_options("${name}" PRIVATE "$<$:${_flag}>") + endforeach() + foreach(_flag IN LISTS PRIK_C_FLAGS) + target_compile_options("${name}" PRIVATE "$<$:${_flag}>") + endforeach() + if(PRIK_LINK_LIBRARIES) + target_link_libraries("${name}" PRIVATE ${PRIK_LINK_LIBRARIES}) + endif() + if(PRIK_LINK_OPTIONS) + target_link_options("${name}" PRIVATE ${PRIK_LINK_OPTIONS}) + endif() +endfunction() diff --git a/docs/user/guide/cmake.md b/docs/user/guide/cmake.md new file mode 100644 index 000000000..7e5cf31fc --- /dev/null +++ b/docs/user/guide/cmake.md @@ -0,0 +1,110 @@ +--- +title: CMake Builds +description: Build PRIK Python extensions from an existing or generated CMake project +audience: users +prerequisites: building the shared library, CMake, Python development files +related: building-shared-library.md, ../reference/cli-commands.md +status: maintained +publication: reviewed +--- + +# CMake Builds + +Use CMake when its toolchain, dependency targets, and build scheduling should +own compilation and linking. PRIK still parses the native inputs, completes +wrapper policy, and generates the wrapper and any Fortran bridge sources. By +default, PRIK uses CMake's selected C or Fortran compiler for source analysis. + +## Existing CMake project + +Install PRIK, make its `cmake` directory available through +`CMAKE_MODULE_PATH`, and include the packaged helper: + +```cmake +cmake_minimum_required(VERSION 3.20) + +project(MyPhysics LANGUAGES C Fortran) + +find_package( + Python + COMPONENTS Interpreter Development.Module + REQUIRED +) + +# Ask PRIK's Python environment for its packaged CMake helper. +execute_process( + COMMAND "${Python_EXECUTABLE}" -c "from prik.cmake import cmake_module_dir; print(cmake_module_dir().as_posix())" + RESULT_VARIABLE PRIK_CMAKE_MODULE_RESULT + OUTPUT_VARIABLE PRIK_CMAKE_MODULE_DIR + ERROR_VARIABLE PRIK_CMAKE_MODULE_ERROR + OUTPUT_STRIP_TRAILING_WHITESPACE +) +if(NOT PRIK_CMAKE_MODULE_RESULT EQUAL 0) + message(FATAL_ERROR "Cannot locate UsePRIK.cmake: ${PRIK_CMAKE_MODULE_ERROR}") +endif() +list(APPEND CMAKE_MODULE_PATH "${PRIK_CMAKE_MODULE_DIR}") +include(UsePRIK) + +prik_add_module( + physics + FORTRAN_SOURCES + solver.f90 + matrix.f90 +) +``` + +Then configure and build the extension: + +```bash +cmake -S . -B build +cmake --build build +``` + +`prik_add_module()` also accepts `SOURCES` for source-first input, `CONTRACT` +with `FORTRAN_SOURCES` or `C_SOURCES` for an authored semantic `.pyi`, +`INCLUDE_DIRS`, `MODULE_DIRS`, language-specific compile flags, +`LINK_LIBRARIES`, `LINK_OPTIONS`, and additional `PRIK_ARGS`. Use +`NO_COMPILE_INPUT_SOURCES` when `SOURCES` supplies only the public interface +and `FORTRAN_SOURCES` or `C_SOURCES` supplies its implementation. The +generated wrapper sources are custom-command outputs. Changing a semantic +source or contract regenerates them before CMake compiles the target; CMake +recompiles contract-first native implementations independently. + +External dependencies remain CMake dependencies. For example, CMake can find +BLAS and pass its target to the PRIK extension: + +```cmake +find_package(BLAS REQUIRED) + +prik_add_module( + blas_example + FORTRAN_SOURCES blas_example.f90 + LINK_LIBRARIES BLAS::BLAS +) +``` + +The same form accepts normal project targets such as `native_math` and +`OpenMP::OpenMP_Fortran`; they remain target-oriented CMake link inputs. + +## Standalone generated project + +Generate a small CMake project from native sources: + +```bash +python3 -m prik generate --cmake solver.f90 --out-dir build/solver +cmake -S build/solver -B build/solver/cmake-build +cmake --build build/solver/cmake-build +``` + +For an authored contract, provide its implementation sources as usual: + +```bash +python3 -m prik generate --cmake contracts/solver.pyi \ + --native-fortran-sources solver.f90 \ + --out-dir build/solver +``` + +The generated `CMakeLists.txt` loads `UsePRIK.cmake` and calls +`prik_add_module()`. `UsePRIK.cmake` integrates PRIK into an existing CMake +project; `prik generate --cmake` creates a standalone CMake project that uses +that same helper. diff --git a/docs/user/guide/index.md b/docs/user/guide/index.md index 228a22a30..c8296a717 100644 --- a/docs/user/guide/index.md +++ b/docs/user/guide/index.md @@ -45,7 +45,9 @@ workflow that matches how you run PRIK. ## Build Workflows - [Building the Shared Library](building-shared-library.md) — compilers, - source sets, output placement, and Makefiles + source sets, output placement, Makefiles, and CMake +- [CMake Builds](cmake.md) — integrate PRIK into an existing CMake project or + generate a standalone one - [IPython and Jupyter Notebooks](notebooks.md) — compile Fortran and C cells and edit semantic contracts interactively diff --git a/docs/user/reference/cli-commands.md b/docs/user/reference/cli-commands.md index 25c26e41c..27705fb6e 100644 --- a/docs/user/reference/cli-commands.md +++ b/docs/user/reference/cli-commands.md @@ -22,7 +22,7 @@ python3 -m prik {parse,semantics,generate,probe} [OPTIONS] ... | no subcommand | Builds one importable extension from Fortran source, a supported C source, or a semantic `.pyi` contract. | | `parse` | Prints parser facts and diagnostics. | | `semantics` | Prints a human-readable semantic-IR report; `--json` selects the complete JSON record. | -| `generate` | Writes `.pyi` contracts, wrapper sources, or a Makefile without compiling. | +| `generate` | Writes `.pyi` contracts, wrapper sources, a Makefile, or a CMake project without compiling. | | `probe` | Prints compiler-target datatype and ABI facts. | ## Getting help @@ -197,7 +197,7 @@ Support](../language-support/c-support.md) before building a C API. `generate` requires exactly one output mode: ```bash -python3 -m prik generate (--pyi | --sources | --makefile) INPUT [INPUT ...] [OPTIONS] +python3 -m prik generate (--pyi | --sources | --makefile | --cmake) INPUT [INPUT ...] [OPTIONS] python3 -m prik generate (--sources | --makefile) --build-manifest PATH [OVERRIDES] ``` @@ -206,11 +206,14 @@ python3 -m prik generate (--sources | --makefile) --build-manifest PATH [OVERRID | `--pyi` | Writes the editable semantic `.pyi` contract. | | `--sources` | Writes wrapper sources without compiling. | | `--makefile` | Writes wrapper sources, the replay manifest when applicable, and `Makefile.prik`. | +| `--cmake` | Writes a standalone `CMakeLists.txt` that uses `UsePRIK.cmake`. | +| `--module-name NAME` | Sets the Python module name used by generated wrapper sources; `--cmake` requires an ASCII C target name. | ```bash python3 -m prik generate --pyi points.f90 --out contracts/points python3 -m prik generate --sources points.f90 --out-dir build python3 -m prik generate --makefile points.f90 --out-dir build +python3 -m prik generate --cmake points.f90 --out-dir build/points ``` For a C source contract, `--language c` is valid with `--pyi`: @@ -219,9 +222,11 @@ For a C source contract, `--language c` is valid with `--pyi`: python3 -m prik generate --pyi --language c path/to/api.c --out api.pyi ``` -`--sources` and `--makefile` still run preprocessing and semantic policy to -produce a valid wrapper plan; they skip object compilation and linking, and -use `--out-dir`. With no `--out`, `generate --pyi` prints every generated +`--sources` and `--makefile` run preprocessing and semantic policy to produce +a valid wrapper plan; they skip object compilation and linking, and use +`--out-dir`. `--cmake` writes the CMake project; its CMake configuration later +runs PRIK's wrapper-generation step, while CMake owns compilation and linking. +With no `--out`, `generate --pyi` prints every generated contract. For Fortran, `--out PATH` names a package directory containing `__init__.pyi` and any module leaves. For C, it names the single output `.pyi` file. Bare `--out` writes beside the inputs. The [source-to-contract diff --git a/docs/user/reference/index.md b/docs/user/reference/index.md index e6373325b..ecec46739 100644 --- a/docs/user/reference/index.md +++ b/docs/user/reference/index.md @@ -21,6 +21,8 @@ you have not. - [Python API](python-api.md) — the build entrypoints and advanced package imports. - [Build manifests and Makefiles](configuration-files.md) — how both files are generated, what they contain, and how to build or replay them. +- [CMake Builds](../guide/cmake.md) — the packaged CMake helper and standalone + CMake project generation. ## Contracts diff --git a/mkdocs.yml b/mkdocs.yml index 886c6d550..fd5caf6ec 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -71,6 +71,7 @@ nav: - Symbols, Headers, and Dependencies: user/guide/c/symbols-headers-and-dependencies.md - Build Workflows: - Building the Shared Library: user/guide/building-shared-library.md + - CMake Builds: user/guide/cmake.md - IPython and Jupyter Notebooks: user/guide/notebooks.md - Tutorials: - Run PRIK in a Notebook: user/tutorials/notebook-quickstart.md diff --git a/prik/cli.py b/prik/cli.py index b93d03b17..35cf88e20 100644 --- a/prik/cli.py +++ b/prik/cli.py @@ -62,7 +62,7 @@ _PARSE_USAGE = "%(prog)s INPUT [INPUT ...] [OPTIONS]" _SEMANTICS_USAGE = "%(prog)s INPUT [INPUT ...] [OPTIONS]" _GENERATE_USAGE = ( - "%(prog)s (--pyi | --sources | --makefile)\n" + "%(prog)s (--pyi | --sources | --makefile | --cmake)\n" " INPUT [INPUT ...] [OPTIONS]\n" " %(prog)s (--sources | --makefile)\n" " --build-manifest PATH [OVERRIDES]" @@ -161,7 +161,10 @@ " python3 -m prik generate --sources points.f90 --out-dir build\n" "\n" " Reproducible Makefile build:\n" - " python3 -m prik generate --makefile points.f90 --out-dir build\n\n" + " python3 -m prik generate --makefile points.f90 --out-dir build\n" + "\n" + " Standalone CMake project:\n" + " python3 -m prik generate --cmake points.f90 --out-dir build/points\n\n" f"{_POINTS_EXAMPLE_HELP}" ) _PROBE_HELP_EPILOG = ( @@ -954,7 +957,9 @@ def _wrapper_compile_options_used(args: argparse.Namespace) -> bool: def _is_wrapper_build(args: argparse.Namespace) -> bool: """Return whether the command projects and renders a wrapper plan.""" - return args.command == "build" or (args.command == "generate" and (args.generate_sources or args.makefile)) + return args.command == "build" or ( + args.command == "generate" and (args.generate_sources or args.makefile or getattr(args, "cmake", False)) + ) def _has_semantic_stage(args: argparse.Namespace) -> bool: @@ -1070,11 +1075,17 @@ def _validate_wrapper_build_options(args: argparse.Namespace, parser: argparse.A if not _is_wrapper_build(args): return if args.command == "generate" and args.out is not None: - parser.error("generate --sources/--makefile uses --out-dir, not --out") + parser.error("generate --sources/--makefile/--cmake uses --out-dir, not --out") + if args.command == "generate" and getattr(args, "module_name", None) is not None: + _validate_wrapper_out(argparse.Namespace(out=args.module_name), parser) + if getattr(args, "plan_only", False) and not (args.command == "generate" and args.generate_sources): + parser.error("--plan requires generate --sources") if args.command == "build": _validate_wrapper_out(args, parser) if _wrapper_build_uses_manifest(args): + if getattr(args, "cmake", False) or getattr(args, "plan_only", False): + parser.error("generate --cmake/--plan requires source or contract inputs, not --build-manifest") _validate_manifest_wrapper_options(args, parser) return @@ -1153,6 +1164,8 @@ def _validate_pyi_generation_options(args: argparse.Namespace, parser: argparse. invalid.append("--out-dir") if args.build_manifest is not None: invalid.append("--build-manifest") + if getattr(args, "module_name", None) is not None: + invalid.append("--module-name") if _native_link_options_used(args): invalid.append("native link options") if _wrapper_compile_options_used(args) or args.strict_wrapper_names: @@ -1319,6 +1332,8 @@ def _wrapper_shared_library_alias_path(result, raw_out: str | None) -> Path: def _wrapper_output_name(args: argparse.Namespace) -> str | None: + if getattr(args, "module_name", None) is not None: + return args.module_name if getattr(args, "out", None) is None: return None return Path(args.out).stem @@ -1411,6 +1426,17 @@ def _run_stage_reports_with_diagnostics(args: argparse.Namespace, preprocessing: def _run_wrap_build(args: argparse.Namespace, preprocessing: PreprocessingConfig): + if getattr(args, "cmake", False): + from prik.cmake import write_cmake_project + + return write_cmake_project( + paths=args.paths, + output_dir=getattr(args, "out_dir", None) or "__prik__", + language=args.language, + args=args, + native_link_items=_cli_native_link_items(getattr(args, "native_link_items", None)), + ) + from prik.pipeline.build import ( _build_manifest_native_language, build_c_extension, @@ -1467,6 +1493,7 @@ def record_total_build_time(elapsed: float) -> None: positional_only=getattr(args, "positional_only", False), makefile=getattr(args, "makefile", False), generate_sources=getattr(args, "generate_sources", False), + _plan_only=getattr(args, "plan_only", False), jobs=getattr(args, "jobs", None), standard_logicals=getattr(args, "standard_logicals", True), verbose=1 if getattr(args, "verbose", False) else 0, @@ -1509,6 +1536,7 @@ def record_total_build_time(elapsed: float) -> None: positional_only=getattr(args, "positional_only", False), makefile=getattr(args, "makefile", False), generate_sources=getattr(args, "generate_sources", False), + _plan_only=getattr(args, "plan_only", False), jobs=getattr(args, "jobs", None), verbose=1 if getattr(args, "verbose", False) else 0, wrapper_compiler_debug=getattr(args, "wrapper_compiler_debug", False), @@ -1550,6 +1578,7 @@ def record_total_build_time(elapsed: float) -> None: native_include_dirs=_cli_build_include_dirs(args), makefile=getattr(args, "makefile", False), generate_sources=getattr(args, "generate_sources", False), + _plan_only=getattr(args, "plan_only", False), jobs=getattr(args, "jobs", None), verbose=1 if getattr(args, "verbose", False) else 0, wrapper_compiler_debug=getattr(args, "wrapper_compiler_debug", False), @@ -1897,6 +1926,10 @@ def _print_wrap_build_output(args: argparse.Namespace, result) -> None: _print_verbose_total_build_time(args) return + if payload.get("cmake_project"): + print(f"Generated CMake project: {payload['cmake_project']}") + return + if payload.get("compiled", True): print(f"Built extension: {payload['shared_library']}") elif payload.get("build_makefile"): @@ -2369,6 +2402,8 @@ def _add_diagnostic_controls(group: argparse._ArgumentGroup, *, allow_verbose: b "pyi": False, "generate_sources": False, "makefile": False, + "cmake": False, + "plan_only": False, "show_vars": False, "print_limit": None, "vars_limit": None, @@ -2393,6 +2428,7 @@ def _add_diagnostic_controls(group: argparse._ArgumentGroup, *, allow_verbose: b "wrapper_fortran_flags": None, "wrapper_c_flags": None, "out": None, + "module_name": None, "out_dir": None, "verbose": False, "json": False, @@ -2735,11 +2771,16 @@ def _generate_parser(argv: list[str]) -> argparse.ArgumentParser: action="store_true", help="Generate wrapper sources and Makefile.prik without compiling", ) + modes.add_argument( + "--cmake", + action="store_true", + help="Generate a standalone CMake project that uses UsePRIK.cmake", + ) positional_group = parser.add_argument_group("positional arguments") _add_paths( positional_group, metavar="INPUT", - help_text="Source input(s), or one semantic .pyi contract for --sources/--makefile", + help_text="Source input(s), or one semantic .pyi contract for --sources/--makefile/--cmake", ) input_group = parser.add_argument_group("input options") _add_language_option( @@ -2771,8 +2812,14 @@ def _generate_parser(argv: list[str]) -> argparse.ArgumentParser: json_help="Print generated artifact metadata as JSON", out_help="Contract package directory for --pyi; bare --out writes beside inputs", out_metavar="PATH", - out_dir_help="Artifact directory for --sources/--makefile", + out_dir_help="Artifact directory for --sources/--makefile/--cmake", + ) + output_group.add_argument( + "--module-name", + metavar="NAME", + help="Python module name for generated wrapper sources", ) + output_group.add_argument("--plan", dest="plan_only", action="store_true", help=argparse.SUPPRESS) diagnostic_group = parser.add_argument_group("diagnostic options") _add_diagnostic_controls(diagnostic_group) return parser diff --git a/prik/cmake.py b/prik/cmake.py new file mode 100644 index 000000000..25bf74922 --- /dev/null +++ b/prik/cmake.py @@ -0,0 +1,305 @@ +"""Generate small CMake projects that use PRIK's packaged CMake helper.""" + +from __future__ import annotations + +from collections.abc import Iterable +from dataclasses import dataclass +import os +from pathlib import Path +import shlex +import sys +import sysconfig + + +@dataclass(frozen=True) +class CMakeProjectResult: + """Describe a generated standalone CMake project.""" + + module_name: str + output_dir: Path + cmake_lists: Path + + def to_dict(self) -> dict[str, object]: + """Return the generated project paths in the CLI result shape.""" + return { + "cmake_project": str(self.cmake_lists), + "module_name": self.module_name, + "output_dir": str(self.output_dir), + } + + +@dataclass(frozen=True) +class _CMakeModuleInputs: + """Normalized source declarations for one generated helper call.""" + + module_name: str + semantic_sources: tuple[Path, ...] + contract: Path | None + native_fortran: tuple[Path, ...] + native_c: tuple[Path, ...] + + +def _absolute_paths(paths: Iterable[str | Path]) -> tuple[Path, ...]: + return tuple(Path(path).resolve() for path in paths) + + +def _cmake_string(value: str | Path) -> str: + """Quote one literal for a generated CMake string.""" + return '"' + str(value).replace("\\", "/").replace('"', '\\"') + '"' + + +def _relative_path(path: Path, base: Path) -> str: + return Path(os.path.relpath(path, base)).as_posix() + + +def _helper_path() -> Path: + """Find the helper in a checkout or in the installed data directory.""" + candidates = [Path(__file__).resolve().parent.parent / "cmake" / "UsePRIK.cmake"] + data_root = sysconfig.get_path("data") + if data_root: + candidates.append(Path(data_root) / "share" / "prik" / "cmake" / "UsePRIK.cmake") + candidates.append(Path(sys.prefix) / "share" / "prik" / "cmake" / "UsePRIK.cmake") + for candidate in candidates: + if candidate.is_file(): + return candidate.resolve() + raise FileNotFoundError("Packaged PRIK CMake helper not found: cmake/UsePRIK.cmake") + + +def cmake_module_dir() -> Path: + """Return the directory containing PRIK's packaged CMake helper.""" + return _helper_path().parent + + +def _append_block(lines: list[str], keyword: str, values: Iterable[str], *, base: Path) -> None: + resolved = tuple(Path(value) for value in values) + if not resolved: + return + lines.append(f" {keyword}") + lines.extend(f" {_cmake_string(_relative_path(path, base))}" for path in resolved) + + +def _append_values(lines: list[str], keyword: str, values: Iterable[str]) -> None: + values = tuple(str(value) for value in values) + if not values: + return + lines.append(f" {keyword}") + lines.extend(f" {_cmake_string(value)}" for value in values) + + +def _flag_values(raw_flags: Iterable[str] | None) -> tuple[str, ...]: + values: list[str] = [] + for raw in raw_flags or (): + values.extend(shlex.split(str(raw))) + return tuple(values) + + +def _prik_args(args) -> tuple[str, ...]: + """Keep generation-affecting CLI options in the generated CMake call.""" + result: list[str] = [] + + def add_option(option: str, value: object | None = None) -> None: + if value is None: + result.append(option) + else: + result.append(f"{option}={value}") + + if args.compiler: + add_option("--compiler", args.compiler) + for option, values in ( + ("--define", args.defines), + ("--undef", args.undefs), + ("--compiler-arg", args.compiler_args), + ("--public-include", args.public_includes), + ("--private-include", args.private_includes), + ): + for value in values or (): + add_option(option, value) + for option, value in ( + ("--preprocessor-adapter", args.preprocessor_adapter), + ("--preprocess-template", args.preprocess_template), + ("--std", args.std), + ("--compile-commands", args.compile_commands), + ): + if value: + add_option(option, value) + if args.include_exposure != "reachable-project": + add_option("--include-exposure", args.include_exposure) + for option, values in ( + ("--wrapper-fortran-flags", args.wrapper_fortran_flags), + ("--wrapper-c-flags", args.wrapper_c_flags), + ("--collision-adapter", args.collision_adapters), + ): + if values: + result.extend(f"{option}={value}" for value in values) + for option, enabled in ( + ("--strict-wrapper-names", args.strict_wrapper_names), + ("--assume-intent-in-scalars", args.assume_intent_in_scalars), + ("--wrapper-compiler-debug", args.wrapper_compiler_debug), + ("--collision-adapter-all", args.collision_adapter_all), + ("--positional-only", args.positional_only), + ("--no-standard-logicals", not args.standard_logicals), + ): + if enabled: + add_option(option) + return tuple(result) + + +def _link_values( + args, + *, + base: Path, + native_link_items: Iterable[dict[str, object]], +) -> tuple[tuple[str, ...], tuple[str, ...]]: + libraries = [str(Path(path).resolve()) for path in (args.native_objects or ())] + for item in native_link_items: + kind = item["kind"] + if kind in {"object", "archive", "shared_library"}: + libraries.append(str(Path(str(item["path"])).resolve())) + elif kind == "named_library": + libraries.append(str(item["name"])) + elif kind == "linker_argument": + # CMake permits private linker flags among target_link_libraries + # items, which retains the explicit PRIK link-item order. + libraries.append(str(item["argument"])) + else: # pragma: no cover - the CLI normalizer rejects this first. + raise ValueError(f"Unsupported native link item kind: {kind!r}") + libraries.extend(_flag_values(args.native_libraries)) + options: list[str] = [f"-L{Path(path).resolve()}" for path in (args.native_library_dirs or ())] + return tuple( + _relative_path(Path(value), base) if Path(value).is_absolute() else value for value in libraries + ), tuple(options) + + +def _module_inputs(*, paths: Iterable[str | Path], args) -> _CMakeModuleInputs: + input_paths = _absolute_paths(paths) + if not input_paths: + raise ValueError("CMake generation requires at least one input") + if any(path.is_dir() for path in input_paths): + raise ValueError("generate --cmake expects source files or one semantic .pyi contract, not directories") + + contract = input_paths[0] if input_paths[0].suffix.lower() == ".pyi" else None + if contract is not None and len(input_paths) != 1: + raise ValueError("generate --cmake accepts one semantic .pyi contract") + if contract is not None: + default_module_name = contract.parent.name if contract.name == "__init__.pyi" else contract.stem + else: + default_module_name = input_paths[0].stem + module_name = args.module_name or default_module_name + if not module_name.isascii() or not module_name.isidentifier(): + raise ValueError(f"CMake generation requires a valid ASCII Python/C module name: {module_name!r}") + + native_fortran = _absolute_paths(args.native_fortran_sources or ()) + native_c = _absolute_paths(args.native_c_sources or ()) + if args.no_compile_input_sources and not native_fortran and not native_c: + raise ValueError("generate --cmake --no-compile-input-sources requires native implementation sources") + return _CMakeModuleInputs( + module_name=module_name, + semantic_sources=input_paths, + contract=contract, + native_fortran=native_fortran, + native_c=native_c, + ) + + +def _project_preamble(*, module_name: str, languages: str) -> list[str]: + return [ + "cmake_minimum_required(VERSION 3.20)", + "", + f"project({module_name} LANGUAGES {languages})", + "", + "find_package(", + " Python", + " COMPONENTS Interpreter Development.Module", + " REQUIRED", + ")", + "", + "execute_process(", + ' COMMAND "${Python_EXECUTABLE}" -c "from prik.cmake import cmake_module_dir; print(cmake_module_dir().as_posix())"', + " RESULT_VARIABLE PRIK_CMAKE_MODULE_RESULT", + " OUTPUT_VARIABLE PRIK_CMAKE_MODULE_DIR", + " ERROR_VARIABLE PRIK_CMAKE_MODULE_ERROR", + " OUTPUT_STRIP_TRAILING_WHITESPACE", + ")", + "if(NOT PRIK_CMAKE_MODULE_RESULT EQUAL 0)", + ' message(FATAL_ERROR "Cannot locate UsePRIK.cmake: ${PRIK_CMAKE_MODULE_ERROR}")', + "endif()", + 'list(APPEND CMAKE_MODULE_PATH "${PRIK_CMAKE_MODULE_DIR}")', + "include(UsePRIK)", + "", + "prik_add_module(", + f" {module_name}", + ] + + +def _append_source_declarations( + lines: list[str], + inputs: _CMakeModuleInputs, + *, + language: str, + no_compile_input_sources: bool, + base: Path, +) -> None: + if no_compile_input_sources: + lines.append(" NO_COMPILE_INPUT_SOURCES") + if inputs.contract is not None: + lines.append(f" CONTRACT {_cmake_string(_relative_path(inputs.contract, base))}") + elif not no_compile_input_sources and not inputs.native_fortran and not inputs.native_c: + keyword = "C_SOURCES" if language == "c" else "FORTRAN_SOURCES" + _append_block(lines, keyword, inputs.semantic_sources, base=base) + return + else: + _append_block(lines, "SOURCES", inputs.semantic_sources, base=base) + _append_block(lines, "FORTRAN_SOURCES", inputs.native_fortran, base=base) + _append_block(lines, "C_SOURCES", inputs.native_c, base=base) + + +def _append_build_options( + lines: list[str], + *, + args, + project_dir: Path, + native_link_items: Iterable[dict[str, object]], +) -> None: + _append_block(lines, "INCLUDE_DIRS", _absolute_paths(args.include_dirs or ()), base=project_dir) + fortran_flags = (*_flag_values(args.native_compile_flags), *_flag_values(args.wrapper_fortran_flags)) + c_flags = (*_flag_values(args.native_c_compile_flags), *_flag_values(args.wrapper_c_flags)) + libraries, link_options = _link_values(args, base=project_dir, native_link_items=native_link_items) + if args.lto: + fortran_flags = (*fortran_flags, "-flto") + c_flags = (*c_flags, "-flto") + link_options = (*link_options, "-flto") + _append_values(lines, "FORTRAN_FLAGS", fortran_flags) + _append_values(lines, "C_FLAGS", c_flags) + _append_values(lines, "LINK_LIBRARIES", libraries) + _append_values(lines, "LINK_OPTIONS", link_options) + _append_values(lines, "PRIK_ARGS", _prik_args(args)) + + +def write_cmake_project( + *, + paths: Iterable[str | Path], + output_dir: str | Path, + language: str, + args, + native_link_items: Iterable[dict[str, object]] = (), +) -> CMakeProjectResult: + """Write a readable standalone project backed by ``UsePRIK.cmake``.""" + project_dir = Path(output_dir).resolve() + project_dir.mkdir(parents=True, exist_ok=True) + inputs = _module_inputs(paths=paths, args=args) + cmake_module_dir() + project_languages = "C Fortran" if language == "fortran" or inputs.native_fortran else "C" + lines = _project_preamble(module_name=inputs.module_name, languages=project_languages) + _append_source_declarations( + lines, + inputs, + language=language, + no_compile_input_sources=args.no_compile_input_sources, + base=project_dir, + ) + _append_build_options(lines, args=args, project_dir=project_dir, native_link_items=native_link_items) + lines.append(")") + + cmake_lists = project_dir / "CMakeLists.txt" + cmake_lists.write_text("\n".join(lines) + "\n", encoding="utf-8") + return CMakeProjectResult(module_name=inputs.module_name, output_dir=project_dir, cmake_lists=cmake_lists) diff --git a/prik/compiler/native_support.py b/prik/compiler/native_support.py index 144e51bf0..a9fe7a93e 100644 --- a/prik/compiler/native_support.py +++ b/prik/compiler/native_support.py @@ -29,12 +29,21 @@ def _numpy_version_header() -> str: return header +def native_support_output_paths(imports, *, prik_dirpath) -> tuple[Path, ...]: + """Return build-relevant files written for requested native support.""" + if not any(name == _NATIVE_SUPPORT_IMPORT or name.startswith(f"{_NATIVE_SUPPORT_IMPORT}/") for name in imports): + return () + destination = Path(prik_dirpath) / _NATIVE_SUPPORT_IMPORT + return destination / "prik_binding.h", destination / "numpy_version.h" + + def install_native_support(imports, *, prik_dirpath, verbose: bool | int = False) -> None: """Write header-only native binding support when a generated binding imports it.""" - if not any(name == _NATIVE_SUPPORT_IMPORT or name.startswith(f"{_NATIVE_SUPPORT_IMPORT}/") for name in imports): + outputs = native_support_output_paths(imports, prik_dirpath=prik_dirpath) + if not outputs: return - destination = Path(prik_dirpath) / _NATIVE_SUPPORT_IMPORT + destination = outputs[0].parent if verbose: print(f">> Write native support: {destination}") with FileLock(str(destination.with_suffix(".lock"))): diff --git a/prik/pipeline/build.py b/prik/pipeline/build.py index b8becc7a3..b3287f3ab 100644 --- a/prik/pipeline/build.py +++ b/prik/pipeline/build.py @@ -34,7 +34,7 @@ from prik.compiler.objects import ObjectFile from prik.compiler.compilers import Compiler, get_condaless_search_path -from prik.compiler.native_support import install_native_support +from prik.compiler.native_support import install_native_support, native_support_output_paths from prik.parsers.c import parse_c_file from prik.parsers.c.cli import attach_preprocessing_recipe from prik.parsers.fortran.parser import parse_fortran_project @@ -1093,19 +1093,48 @@ def _build_generated_wrapper_extension( compiler: Compiler | None = None, compile_jobs: int | None = None, verbose: bool | int = False, + _plan_only: bool = False, ) -> WrapperBuildResult: - """Write, compile, and link one complete generated wrapper.""" - # Materialize the canonical wrapper output before creating compiler inputs. + """Write, compile, and link one complete generated wrapper. + + ``_plan_only`` preserves the completed wrapper and native plans while + returning their deterministic generated-source paths without writing, + compiling, or linking. Build integrations use that narrow query to declare + their own dependency graph before requesting normal source generation. + """ + # Freeze the canonical wrapper before selecting materialization or planning. rendered.freeze() output_path = Path(output_dir) - output_path.mkdir(parents=True, exist_ok=True) shared_output_path = Path(shared_library_output_dir) if shared_library_output_dir is not None else output_path + resolved_native_build_plan = native_build_plan or NativeBuildPlan() + if _plan_only: + generated_sources = tuple(_generated_source_output_path(output_path, path) for path in rendered.generated_files) + native_support_imports = _generated_wrapper_native_support_imports(rendered.native_support_keys) + native_support_files = native_support_output_paths( + native_support_imports, + prik_dirpath=output_path, + ) + return WrapperBuildResult( + sources=tuple(Path(source) for source in sources), + module_name=rendered.module_name, + output_dir=output_path, + # This path is informational in a plan-only result; CMake owns the + # real extension suffix and output location. + shared_library=shared_output_path / f"{rendered.module_name}.so", + build_makefile=None, + compiled=False, + generated_sources=generated_sources, + generated_files=(*generated_sources, *native_support_files), + native_build_plan=resolved_native_build_plan, + native_generated_code_groups=rendered.native_generated_code_groups, + ) + + output_path.mkdir(parents=True, exist_ok=True) shared_output_path.mkdir(parents=True, exist_ok=True) _write_generated_wrapper_sources(rendered, output_path, verbose=verbose) # Prepare generated-object inputs and their native support files. compiler = compiler or _new_compiler() - resolved_native_build_plan = native_build_plan or NativeBuildPlan() bridge_objects, binding_objects = _generated_wrapper_object_stages( rendered, output_path, @@ -3294,6 +3323,7 @@ def build_fortran_extension( wrapper_c_flags: Iterable[str] | None = None, standard_logicals: bool = True, _on_total_build_time: Callable[[float], None] | None = None, + _plan_only: bool = False, ) -> WrapperBuildResult: """Build a Python extension from one or more Fortran source files. @@ -3395,7 +3425,8 @@ def build_fortran_extension( # 1. Collect the source and native implementation inputs. source_paths = _source_paths(sources) output_path, shared_library_output_path = _wrapper_output_paths(output_dir) - output_path.mkdir(parents=True, exist_ok=True) + if not _plan_only: + output_path.mkdir(parents=True, exist_ok=True) preprocessing = preprocessing or _default_preprocessing_config() supplemental_source_paths = tuple(Path(path) for path in (native_fortran_sources or ())) input_implementation_paths = source_paths if compile_input_sources else () @@ -3437,7 +3468,7 @@ def build_fortran_extension( collision_adapter_all=collision_adapter_all, positional_only=positional_only, ) - contract_files = _write_build_contract_package(source_modules, output_path, verbose=verbose) + contract_files = () if _plan_only else _write_build_contract_package(source_modules, output_path, verbose=verbose) # 4. Prepare native compilation, dependency batches, and link inputs. wrapper_fortran_flags = _compiler_flags(wrapper_fortran_flags) @@ -3466,6 +3497,7 @@ def build_fortran_extension( compiler=compiler, compile_jobs=1 if generation_only else compile_jobs, verbose=verbose, + _plan_only=_plan_only, ) result = _finalize_build_mode( result, @@ -3517,6 +3549,7 @@ def build_c_extension( wrapper_c_flags: Iterable[str] | None = None, standard_logicals: bool = True, _on_total_build_time: Callable[[float], None] | None = None, + _plan_only: bool = False, ) -> WrapperBuildResult: """Build a direct-only C extension from explicit C implementation sources. @@ -3619,12 +3652,14 @@ def build_c_extension( collision_adapter_all=collision_adapter_all, positional_only=positional_only, ) - output_path.mkdir(parents=True, exist_ok=True) - contract_files = _write_build_contract_package( - tuple(_wrapped_c_translation_unit(module) for module in source_modules), - output_path, - verbose=verbose, - ) + contract_files = () + if not _plan_only: + output_path.mkdir(parents=True, exist_ok=True) + contract_files = _write_build_contract_package( + tuple(_wrapped_c_translation_unit(module) for module in source_modules), + output_path, + verbose=verbose, + ) native_source_objects, native_build_plan = _prepare_native_build_plan(native_inputs, output_path=output_path) wrapper_fortran_flags = _compiler_flags(wrapper_fortran_flags) wrapper_c_flags = _compiler_flags(wrapper_c_flags) @@ -3642,6 +3677,7 @@ def build_c_extension( compiler=compiler, compile_jobs=1 if generation_only else compile_jobs, verbose=verbose, + _plan_only=_plan_only, ) result = _finalize_build_mode( result, @@ -3691,6 +3727,7 @@ def build_pyi_extension( wrapper_c_flags: Iterable[str] | None = None, standard_logicals: bool = True, _on_total_build_time: Callable[[float], None] | None = None, + _plan_only: bool = False, ) -> WrapperBuildResult: """Build a Python extension from an editable semantic ``.pyi`` contract. @@ -3823,7 +3860,8 @@ def build_pyi_extension( collision_adapter_all=collision_adapter_all, positional_only=positional_only, ) - output_path.mkdir(parents=True, exist_ok=True) + if not _plan_only: + output_path.mkdir(parents=True, exist_ok=True) # 3. Prepare native compilation and link inputs before selecting the compiler. native_source_objects, native_build_plan = _prepare_native_build_plan(native_inputs, output_path=output_path) @@ -3853,6 +3891,7 @@ def build_pyi_extension( compiler=compiler, compile_jobs=1 if generation_only else compile_jobs, verbose=verbose, + _plan_only=_plan_only, ) result = _with_pyi_manifest( result, diff --git a/pyproject.toml b/pyproject.toml index 6ad6dd8ef..397ecce70 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -87,6 +87,9 @@ include = ["prik*"] [tool.setuptools.package-data] "prik.runtime.native_support" = ["*.h", "LICENSE"] +[tool.setuptools.data-files] +"share/prik/cmake" = ["cmake/UsePRIK.cmake"] + [project.scripts] prik = "prik.cli:main" diff --git a/tests/fortran/infrastructure/building/end_to_end/test_cmake_builds.py b/tests/fortran/infrastructure/building/end_to_end/test_cmake_builds.py new file mode 100644 index 000000000..509684c21 --- /dev/null +++ b/tests/fortran/infrastructure/building/end_to_end/test_cmake_builds.py @@ -0,0 +1,647 @@ +"""CMake integration tests for the PRIK wrapper-generation boundary.""" + +from __future__ import annotations + +import importlib +import json +import os +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile + +import numpy as np +import pytest + + +REPOSITORY_ROOT = Path(__file__).resolve().parents[5] +USE_PRIK_DIR = REPOSITORY_ROOT / "cmake" +BRIDGE_CONTRACT = ( + REPOSITORY_ROOT + / "tests" + / "fortran" + / "functions" + / "end_to_end" + / "fixtures" + / "contracts" + / "free_external" + / "__init__.pyi" +) +BRIDGE_NATIVE = ( + REPOSITORY_ROOT / "tests" / "fortran" / "functions" / "end_to_end" / "fixtures" / "native" / "free_external.f90" +) + + +def _environment() -> dict[str, str]: + environment = os.environ.copy() + existing = environment.get("PYTHONPATH") + environment["PYTHONPATH"] = str(REPOSITORY_ROOT) + (os.pathsep + existing if existing else "") + return environment + + +def _run(command: list[str], *, cwd: Path | None = None) -> subprocess.CompletedProcess[str]: + result = subprocess.run(command, cwd=cwd, env=_environment(), capture_output=True, text=True) + if result.returncode: + raise AssertionError( + f"Command failed ({result.returncode}): {' '.join(command)}\n" + f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}" + ) + return result + + +def _configure_and_build( + project: Path, + build: Path, + *, + language: str, + use_ninja: bool = True, + build_project: bool = True, +) -> None: + command = ["cmake", "-S", str(project), "-B", str(build)] + if use_ninja and shutil.which("ninja"): + command.extend(("-G", "Ninja")) + command.append(f"-DCMAKE_C_COMPILER={shutil.which('gcc')}") + if language == "fortran": + command.append(f"-DCMAKE_Fortran_COMPILER={shutil.which('gfortran')}") + _run(command) + if build_project: + _run(["cmake", "--build", str(build), "-j2"]) + + +def _import_extension(module_name: str, build: Path): + artifacts = tuple(build.rglob(f"{module_name}*.so")) + assert artifacts, f"no CMake extension artifact in {build}" + sys.modules.pop(module_name, None) + sys.path.insert(0, str(artifacts[0].parent)) + try: + return importlib.import_module(module_name) + finally: + sys.path.remove(str(artifacts[0].parent)) + + +def _write_project(project: Path, body: str, *, languages: str = "C Fortran") -> None: + project.mkdir(parents=True, exist_ok=True) + (project / "CMakeLists.txt").write_text( + f"""cmake_minimum_required(VERSION 3.20) +project(cmake_test LANGUAGES {languages}) +find_package(Python COMPONENTS Interpreter Development.Module REQUIRED) +list(APPEND CMAKE_MODULE_PATH "{USE_PRIK_DIR.as_posix()}") +include(UsePRIK) +{body} +""", + encoding="utf-8", + ) + + +def _cmake_finds_blas() -> bool: + if shutil.which("cmake") is None or shutil.which("gfortran") is None: + return False + with tempfile.TemporaryDirectory(prefix="prik-cmake-blas-") as probe_directory: + result = subprocess.run( + [ + "cmake", + "--find-package", + "-DNAME=BLAS", + "-DCOMPILER_ID=GNU", + "-DLANGUAGE=Fortran", + "-DMODE=EXIST", + ], + cwd=probe_directory, + capture_output=True, + text=True, + ) + return result.returncode == 0 + + +@pytest.mark.fortran_end_to_end +@pytest.mark.skipif( + shutil.which("cmake") is None or shutil.which("gfortran") is None or shutil.which("gcc") is None, + reason="CMake, gfortran, and gcc are required", +) +def test_use_prik_cmake_builds_source_first_fortran_module(tmp_path: Path): + project = tmp_path / "user project with spaces" + project.mkdir() + (project / "square.f90").write_text( + """real(8) function square(x) result(y) + real(8), intent(in) :: x + y = x * x +end function square +""", + encoding="utf-8", + ) + _write_project( + project, + """prik_add_module( + square + SOURCES square.f90 + FORTRAN_FLAGS -O0 +) +""", + ) + build = project / "build" + _configure_and_build(project, build, language="fortran", build_project=False) + assert not tuple((build / "prik" / "square").glob("*_wrapper.*")) + _run(["cmake", "--build", str(build), "-j2"]) + native_support_header = build / "prik" / "square" / "binding_support" / "prik_binding.h" + assert native_support_header.is_file() + native_support_header.unlink() + _run(["cmake", "--build", str(build), "-j2"]) + assert native_support_header.is_file() + module = _import_extension("square", build) + assert module.square(np.float64(3.0)) == np.float64(9.0) + + +@pytest.mark.fortran_end_to_end +@pytest.mark.skipif( + shutil.which("cmake") is None or shutil.which("gfortran") is None or shutil.which("gcc") is None, + reason="CMake, gfortran, and gcc are required", +) +def test_use_prik_cmake_builds_a_fortran_module(tmp_path: Path): + project = tmp_path / "fortran module" + project.mkdir() + (project / "math_mod.f90").write_text( + """module math_mod +contains + real(8) function add(a, b) result(c) + real(8), intent(in) :: a, b + c = a + b + end function add +end module math_mod +""", + encoding="utf-8", + ) + _write_project( + project, + """prik_add_module( + math_mod + SOURCES math_mod.f90 +) +""", + ) + build = project / "build" + _configure_and_build(project, build, language="fortran") + module = _import_extension("math_mod", build) + assert module.math_mod.add(np.float64(2.0), np.float64(3.0)) == np.float64(5.0) + + +@pytest.mark.fortran_end_to_end +@pytest.mark.skipif( + shutil.which("cmake") is None or shutil.which("gfortran") is None or shutil.which("gcc") is None, + reason="CMake, gfortran, and gcc are required", +) +def test_generate_cmake_builds_standalone_project_in_a_path_with_spaces(tmp_path: Path): + source = tmp_path / "standalone.f90" + source.write_text( + """real(8) function square(x) result(y) + real(8), intent(in) :: x + y = x * x +end function square +""", + encoding="utf-8", + ) + project = tmp_path / "generated project with spaces" + generated = _run( + [ + sys.executable, + "-m", + "prik", + "generate", + "--cmake", + str(source), + "--module-name", + "generated_square", + "--out-dir", + str(project), + "--json", + ] + ) + assert Path(json.loads(generated.stdout)["cmake_project"]) == project / "CMakeLists.txt" + cmake_lists = (project / "CMakeLists.txt").read_text(encoding="utf-8") + assert "include(UsePRIK)" in cmake_lists + assert "prik_add_module(\n generated_square" in cmake_lists + build = project / "cmake-build" + _configure_and_build(project, build, language="fortran", use_ninja=False) + module = _import_extension("generated_square", build) + assert module.square(np.float64(4.0)) == np.float64(16.0) + + +@pytest.mark.fortran_end_to_end +def test_generate_cmake_preserves_ordered_native_link_items(tmp_path: Path): + source = tmp_path / "ordered.f90" + source.write_text("subroutine ordered()\nend subroutine ordered\n", encoding="utf-8") + archive = tmp_path / "libordered.a" + archive.touch() + project = tmp_path / "ordered project" + _run( + [ + sys.executable, + "-m", + "prik", + "generate", + "--cmake", + str(source), + "--native-link-item", + "arg:-Wl,--start-group", + f"archive:{archive}", + "library:ordered", + "arg:-Wl,--end-group", + "--out-dir", + str(project), + ] + ) + cmake_lists = (project / "CMakeLists.txt").read_text(encoding="utf-8") + ordered_items = ( + '"-Wl,--start-group"', + f'"{Path(os.path.relpath(archive, project)).as_posix()}"', + '"ordered"', + '"-Wl,--end-group"', + ) + positions = tuple(cmake_lists.index(item) for item in ordered_items) + assert positions == tuple(sorted(positions)) + + +@pytest.mark.fortran_end_to_end +@pytest.mark.skipif( + shutil.which("cmake") is None or shutil.which("gfortran") is None or shutil.which("gcc") is None, + reason="CMake, gfortran, and gcc are required", +) +def test_generate_cmake_keeps_supplemental_c_sources_out_of_the_python_api(tmp_path: Path): + source = tmp_path / "mixed.f90" + source.write_text( + """real(8) function add_one(value) result(result) + use iso_c_binding, only: c_double + real(8), intent(in) :: value + interface + function native_add_one(input) bind(C, name="native_add_one") result(output) + import c_double + real(c_double), value :: input + real(c_double) :: output + end function native_add_one + end interface + result = native_add_one(value) +end function add_one +""", + encoding="utf-8", + ) + native_c = tmp_path / "native.c" + native_c.write_text("double native_add_one(double value) { return value + 1.0; }\n", encoding="utf-8") + project = tmp_path / "generated mixed project" + _run( + [ + sys.executable, + "-m", + "prik", + "generate", + "--cmake", + str(source), + "--module-name", + "mixed_extension", + "--native-c-sources", + str(native_c), + "--out-dir", + str(project), + ] + ) + cmake_lists = (project / "CMakeLists.txt").read_text(encoding="utf-8") + assert "SOURCES" in cmake_lists + assert "C_SOURCES" in cmake_lists + build = project / "cmake-build" + _configure_and_build(project, build, language="fortran") + module = _import_extension("mixed_extension", build) + assert module.add_one(np.float64(4.0)) == np.float64(5.0) + + +@pytest.mark.fortran_end_to_end +@pytest.mark.skipif( + shutil.which("cmake") is None or shutil.which("gfortran") is None or shutil.which("gcc") is None, + reason="CMake, gfortran, and gcc are required", +) +def test_use_prik_cmake_contract_dependency_regenerates_wrapper(tmp_path: Path): + native = tmp_path / "contract native.f90" + native.write_text( + """real(8) function square(x) result(y) + real(8), intent(in) :: x + y = x * x +end function square +""", + encoding="utf-8", + ) + contracts = tmp_path / "contracts" + _run([sys.executable, "-m", "prik", "generate", "--pyi", str(native), "--out", str(contracts)]) + contract_dir = tmp_path / "contract_example" + contract_dir.mkdir() + contract = contract_dir / "__init__.pyi" + contract_leaf = contract_dir / "marker.pyi" + contract.write_text( + (contracts / "__init__.pyi").read_text(encoding="utf-8") + "\nfrom . import marker\n", + encoding="utf-8", + ) + contract_leaf.write_text("# included contract dependency\n", encoding="utf-8") + project = tmp_path / "contract project" + _write_project( + project, + f"""prik_add_module( + contract_example + CONTRACT "{contract.as_posix()}" + FORTRAN_SOURCES "{native.as_posix()}" +) +""", + ) + build = project / "build" + _configure_and_build(project, build, language="fortran") + module = _import_extension("contract_example", build) + assert module.square(np.float64(3.0)) == np.float64(9.0) + native.write_text(native.read_text(encoding="utf-8").replace("y = x * x", "y = x * x + 1.0"), encoding="utf-8") + native_rebuild = _run(["cmake", "--build", str(build), "--verbose", "-j2"]) + native_output = native_rebuild.stdout + native_rebuild.stderr + assert "contract_native.f90" in native_output + assert "Generate PRIK wrapper sources for contract_example" not in native_output + contract_leaf.write_text(contract_leaf.read_text(encoding="utf-8") + "\n# contract changed\n", encoding="utf-8") + contract_rebuild = _run(["cmake", "--build", str(build), "-j2"]) + contract_output = contract_rebuild.stdout + contract_rebuild.stderr + assert "Generate PRIK wrapper sources for contract_example" in contract_output + + +@pytest.mark.fortran_end_to_end +@pytest.mark.skipif( + shutil.which("cmake") is None or shutil.which("gfortran") is None or shutil.which("gcc") is None, + reason="CMake, gfortran, and gcc are required", +) +def test_use_prik_cmake_compiles_generated_fortran_bridge_and_binding(tmp_path: Path): + project = tmp_path / "bridge project" + _write_project( + project, + f"""prik_add_module( + free_external + CONTRACT "{BRIDGE_CONTRACT.as_posix()}" + FORTRAN_SOURCES "{BRIDGE_NATIVE.as_posix()}" +) +""", + ) + build = project / "build" + _configure_and_build(project, build, language="fortran") + generated = build / "prik" / "free_external" + assert tuple(generated.glob("*.f90")), "the generated Fortran bridge is missing" + assert tuple(generated.glob("*.c")), "the generated C binding is missing" + module = _import_extension("free_external", build) + assert module.free_square(np.int32(6)) == np.int32(36) + + +@pytest.mark.fortran_end_to_end +@pytest.mark.skipif( + shutil.which("cmake") is None or shutil.which("gfortran") is None or shutil.which("gcc") is None, + reason="CMake, gfortran, and gcc are required", +) +def test_use_prik_cmake_reconfigures_when_source_adds_a_fortran_bridge(tmp_path: Path): + project = tmp_path / "routing project" + project.mkdir() + source = project / "routing.f90" + source.write_text( + """integer(c_int) function standalone_direct(value) bind(C, name="standalone_direct_symbol") result(output) + use iso_c_binding + integer(c_int), value, intent(in) :: value + + output = value + 2_c_int +end function standalone_direct +""", + encoding="utf-8", + ) + _write_project( + project, + """prik_add_module( + routing + SOURCES routing.f90 +) +""", + ) + build = project / "build" + _configure_and_build(project, build, language="fortran") + generated = build / "prik" / "routing" + assert not tuple(generated.glob("*.f90")) + module = _import_extension("routing", build) + assert module.standalone_direct(np.int32(4)) == np.int32(6) + + source.write_text( + """integer(c_int) function standalone_direct(value) bind(C, name="standalone_mixed_direct") result(output) + use iso_c_binding + integer(c_int), value, intent(in) :: value + + output = value + 2_c_int +end function standalone_direct + +integer(c_int) function standalone_adapted(value) result(output) + use iso_c_binding + integer(c_int), intent(in) :: value + + output = value + 3_c_int +end function standalone_adapted +""", + encoding="utf-8", + ) + _run(["cmake", "--build", str(build), "-j2"]) + assert tuple(generated.glob("*.f90")), "CMake did not reconfigure the generated bridge source set" + + +@pytest.mark.fortran_end_to_end +@pytest.mark.skipif(shutil.which("cmake") is None or shutil.which("gcc") is None, reason="CMake and gcc are required") +def test_use_prik_cmake_builds_c_source_with_include_directory_and_flag(tmp_path: Path): + project = tmp_path / "c project with spaces" + include_dir = project / "include files" + include_dir.mkdir(parents=True) + (include_dir / "cmath_api.h").write_text("double c_add(double, double);\n", encoding="utf-8") + (project / "cexample.c").write_text( + '#include "cmath_api.h"\n#ifdef PRIK_CMAKE_TEST_FLAG\ndouble c_add(double a, double b) { return a + b + 1.0; }\n#else\ndouble c_add(double a, double b) { return a + b; }\n#endif\n', + encoding="utf-8", + ) + _write_project( + project, + f"""prik_add_module( + cexample + C_SOURCES cexample.c + INCLUDE_DIRS "{include_dir.as_posix()}" + C_FLAGS -DPRIK_CMAKE_TEST_FLAG + PRIK_ARGS --define PRIK_CMAKE_TEST_FLAG +) +""", + languages="C", + ) + build = project / "build" + _configure_and_build(project, build, language="c") + module = _import_extension("cexample", build) + assert module.c_add(np.float64(2.0), np.float64(3.0)) == np.float64(6.0) + + +@pytest.mark.fortran_end_to_end +@pytest.mark.skipif( + shutil.which("cmake") is None or shutil.which("gfortran") is None or shutil.which("gcc") is None, + reason="CMake, gfortran, and gcc are required", +) +@pytest.mark.parametrize( + ("module_arguments", "expected_error_fragment"), + ( + ( + "SOURCES source.f90\n PRIK_ARGS --module-name hijacked", + "PRIK_ARGS cannot override prik_add_module build ownership", + ), + ( + "FORTRAN_SOURCES source.f90\n NO_COMPILE_INPUT_SOURCES", + "NO_COMPILE_INPUT_SOURCES", + ), + ( + "SOURCES source.f90\n NO_COMPILE_INPUT_SOURCES", + "requires native implementation sources", + ), + ), +) +def test_use_prik_cmake_rejects_invalid_generation_ownership( + tmp_path: Path, + module_arguments: str, + expected_error_fragment: str, +): + project = tmp_path / "reserved args" + project.mkdir() + (project / "source.f90").write_text("subroutine source()\nend subroutine source\n", encoding="utf-8") + _write_project( + project, + f"""prik_add_module( + reserved_args + {module_arguments} +) +""", + ) + result = subprocess.run( + ["cmake", "-S", str(project), "-B", str(project / "build")], + env=_environment(), + capture_output=True, + text=True, + ) + assert result.returncode != 0 + assert expected_error_fragment in result.stdout + result.stderr + + +@pytest.mark.fortran_end_to_end +@pytest.mark.skipif( + shutil.which("cmake") is None or shutil.which("gfortran") is None or shutil.which("gcc") is None, + reason="CMake, gfortran, and gcc are required", +) +def test_use_prik_cmake_links_a_normal_fortran_library_target(tmp_path: Path): + project = tmp_path / "external target" + project.mkdir() + (project / "native_math.f90").write_text( + "real(8) function native_add(x, y) result(z)\n" + " real(8), intent(in) :: x, y\n" + " z = x + y\n" + "end function native_add\n", + encoding="utf-8", + ) + (project / "wrapper.f90").write_text( + "real(8) function call_native(x, y) result(z)\n" + " real(8), intent(in) :: x, y\n" + " interface\n" + " function native_add(a, b) result(c)\n" + " real(8), intent(in) :: a, b\n" + " real(8) :: c\n" + " end function native_add\n" + " end interface\n" + " z = native_add(x, y)\n" + "end function call_native\n", + encoding="utf-8", + ) + _write_project( + project, + """add_library(native_math STATIC native_math.f90) +prik_add_module( + external_target + SOURCES wrapper.f90 + LINK_LIBRARIES native_math +) +""", + ) + build = project / "build" + _configure_and_build(project, build, language="fortran") + module = _import_extension("external_target", build) + assert module.call_native(np.float64(2.0), np.float64(3.0)) == np.float64(5.0) + + +@pytest.mark.fortran_end_to_end +@pytest.mark.skipif( + shutil.which("cmake") is None or shutil.which("gfortran") is None or shutil.which("gcc") is None, + reason="CMake, gfortran, and gcc are required", +) +def test_generate_cmake_can_keep_semantic_sources_out_of_native_compilation(tmp_path: Path): + project = tmp_path / "separate implementation" + project.mkdir() + (project / "interface.f90").write_text( + """real(8) function square(value) result(result) + real(8), intent(in) :: value + result = value * value +end function square +""", + encoding="utf-8", + ) + (project / "implementation.f90").write_text( + """real(8) function square(value) result(result) + real(8), intent(in) :: value + result = value * value + 1.0 +end function square +""", + encoding="utf-8", + ) + _run( + [ + sys.executable, + "-m", + "prik", + "generate", + "--cmake", + "--module-name", + "separate_implementation", + "--no-compile-input-sources", + str(project / "interface.f90"), + "--native-fortran-sources", + str(project / "implementation.f90"), + "--out-dir", + str(project), + ] + ) + assert "NO_COMPILE_INPUT_SOURCES" in (project / "CMakeLists.txt").read_text(encoding="utf-8") + build = project / "build" + _configure_and_build(project, build, language="fortran") + module = _import_extension("separate_implementation", build) + assert module.square(np.float64(3.0)) == np.float64(10.0) + + +@pytest.mark.fortran_end_to_end +@pytest.mark.skipif(not _cmake_finds_blas(), reason="CMake cannot discover a Fortran BLAS implementation") +def test_use_prik_cmake_links_a_cmake_discovered_blas_target(tmp_path: Path): + project = tmp_path / "blas target" + (project / "blas_example.f90").parent.mkdir(parents=True) + (project / "blas_example.f90").write_text( + "real(8) function blas_dot(x, y) result(value)\n" + " real(8), intent(in) :: x(2), y(2)\n" + " real(8) ddot\n" + " external ddot\n" + " value = ddot(2, x, 1, y, 1)\n" + "end function blas_dot\n", + encoding="utf-8", + ) + _write_project( + project, + """find_package(BLAS REQUIRED) +if(TARGET BLAS::BLAS) + set(PRIK_TEST_BLAS_TARGET BLAS::BLAS) +else() + set(PRIK_TEST_BLAS_TARGET ${BLAS_LIBRARIES}) +endif() +prik_add_module( + blas_example + FORTRAN_SOURCES blas_example.f90 + LINK_LIBRARIES ${PRIK_TEST_BLAS_TARGET} +) +""", + ) + build = project / "build" + _configure_and_build(project, build, language="fortran") + module = _import_extension("blas_example", build) + assert module.blas_dot(np.array([1.0, 2.0]), np.array([3.0, 4.0])) == np.float64(11.0) diff --git a/tests/fortran/infrastructure/cli/pipeline/test_argument_contract.py b/tests/fortran/infrastructure/cli/pipeline/test_argument_contract.py index ff7a91d81..2aa899086 100644 --- a/tests/fortran/infrastructure/cli/pipeline/test_argument_contract.py +++ b/tests/fortran/infrastructure/cli/pipeline/test_argument_contract.py @@ -1,5 +1,6 @@ """Tests split by stable CLI argument-contract ownership.""" +import argparse import json from pathlib import Path import subprocess @@ -137,7 +138,7 @@ class extra(Opaque): ), ( {"out": "module", "makefile": True}, - "generate --sources/--makefile uses --out-dir, not --out", + "generate --sources/--makefile/--cmake uses --out-dir, not --out", ), ({"parse": True, "print_limit": -1}, "--print-limit must be >= 0"), ( @@ -746,7 +747,7 @@ def assert_group_order(help_text, *headings): assert "preprocessing and datatype measurement" in normalized_semantics_help assert "native and bridge compilation" not in normalized_semantics_help assert "default: gfortran; cc with --language c" in normalized_semantics_help - assert "(--pyi | --sources | --makefile)" in generate_help + assert "(--pyi | --sources | --makefile | --cmake)" in generate_help assert "INPUT [INPUT ...] [OPTIONS]" in generate_help assert "--build-manifest PATH [OVERRIDES]" in generate_help for heading in ( @@ -777,6 +778,7 @@ def assert_group_order(help_text, *headings): assert "--pyi" in generate_help assert "--sources" in generate_help assert "--makefile" in generate_help + assert "--cmake" in generate_help assert "Read an existing prik-build.json and regenerate wrapper artifacts" in normalized_generate_help assert "Compiler used for source analysis and wrapper build files" in normalized_generate_help assert "default: gfortran; cc with --language c" in normalized_generate_help @@ -917,7 +919,9 @@ def test_help_build_routes_to_the_full_default_build_help(): def test_help_build_exposes_every_supported_build_option(): parser = prik_cli._build_parser(["--help"]) help_text = parser.format_help() - option_strings = {option for action in parser._actions for option in action.option_strings} + option_strings = { + option for action in parser._actions if action.help != argparse.SUPPRESS for option in action.option_strings + } assert option_strings assert all(option in help_text for option in option_strings) @@ -935,7 +939,9 @@ def test_help_build_exposes_every_supported_build_option(): def test_subcommand_help_exposes_every_supported_option(parser_factory): parser = parser_factory(["--help"]) help_text = parser.format_help() - option_strings = {option for action in parser._actions for option in action.option_strings} + option_strings = { + option for action in parser._actions if action.help != argparse.SUPPRESS for option in action.option_strings + } assert option_strings assert all(option in help_text for option in option_strings) From fe3423338eaa7725dc5ec50f1e5fbe5e59706c31 Mon Sep 17 00:00:00 2001 From: said Date: Thu, 10 Sep 2026 17:32:46 +0100 Subject: [PATCH 02/15] codex: complete CMake build-plan integration --- .github/workflows/publish-to-pypi.yml | 2 + CHANGELOG.md | 7 +- cmake/UsePRIK.cmake | 169 ++++++- docs/developer/packages/pipeline.md | 8 + docs/user/guide/cmake.md | 53 ++- docs/user/reference/cli-commands.md | 9 +- docs/user/reference/configuration-files.md | 32 +- docs/user/reference/python-api.md | 2 +- prik/cli.py | 44 +- prik/cmake.py | 41 +- prik/compiler/compilers.py | 12 + prik/pipeline/build.py | 290 ++++++++++-- .../compiling/test_logical_interop_flags.py | 22 + .../building/end_to_end/test_cmake_builds.py | 413 +++++++++++++++++- .../pipeline/test_generated_wrapper_build.py | 21 + .../building/pipeline/test_pyi_build_modes.py | 2 +- .../cli/pipeline/test_argument_contract.py | 2 +- 17 files changed, 1012 insertions(+), 117 deletions(-) diff --git a/.github/workflows/publish-to-pypi.yml b/.github/workflows/publish-to-pypi.yml index bdd96ea4b..bc9d9f542 100644 --- a/.github/workflows/publish-to-pypi.yml +++ b/.github/workflows/publish-to-pypi.yml @@ -62,6 +62,8 @@ jobs: "$RUNNER_TEMP/prik-release-check/bin/prik" --version "$RUNNER_TEMP/prik-release-check/bin/python" -c \ 'import importlib.metadata as m, prik; assert prik.__version__ == m.version("prik")' + "$RUNNER_TEMP/prik-release-check/bin/python" -c \ + 'from prik.cmake import cmake_module_dir; assert (cmake_module_dir() / "UsePRIK.cmake").is_file()' "$RUNNER_TEMP/prik-release-check/bin/prik" --help "$RUNNER_TEMP/prik-release-check/bin/python" -m prik --help - name: Upload distributions diff --git a/CHANGELOG.md b/CHANGELOG.md index 9606ff6e7..16eff765a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,12 @@ release tags add a leading `v` to the package version. - Added CMake integration through the packaged `UsePRIK.cmake` helper and a `prik generate --cmake` standalone-project mode. CMake generates PRIK wrapper sources as build outputs and owns native compilation, linking, external - targets, and incremental rebuilds. + targets, and incremental rebuilds while preserving per-source flags, + compiler-required ABI options, preprocessing dependencies, and linker + language from PRIK's completed build plan. + +- `prik-build.json` schema 5 records generated/native compilation-unit ABI + flags and explicit native linker-language requirements. - Array handles support allocatable and pointer arguments, results, module variables, derived fields, optional arguments, and matching ordinary-array diff --git a/cmake/UsePRIK.cmake b/cmake/UsePRIK.cmake index 685b54cf3..a2f34805a 100644 --- a/cmake/UsePRIK.cmake +++ b/cmake/UsePRIK.cmake @@ -50,23 +50,36 @@ function(_prik_append_cli_flags command option flags) set(${command} "${_command}" PARENT_SCOPE) endfunction() -function(_prik_json_string_list output_variable json key) - string(JSON _prik_item_count ERROR_VARIABLE _prik_json_error LENGTH "${json}" "${key}") +function(_prik_json_string_list output_variable json) + set(_prik_json_path ${ARGN}) + string(JSON _prik_item_count ERROR_VARIABLE _prik_json_error LENGTH "${json}" ${_prik_json_path}) if(_prik_json_error) - message(FATAL_ERROR "PRIK did not return ${key}: ${_prik_json_error}") + message(FATAL_ERROR "PRIK did not return ${_prik_json_path}: ${_prik_json_error}") endif() set(_prik_items) if(_prik_item_count GREATER 0) math(EXPR _prik_last_item_index "${_prik_item_count} - 1") foreach(_prik_item_index RANGE 0 ${_prik_last_item_index}) - string(JSON _prik_item GET "${json}" "${key}" ${_prik_item_index}) + string(JSON _prik_item GET "${json}" ${_prik_json_path} ${_prik_item_index}) list(APPEND _prik_items "${_prik_item}") endforeach() endif() set(${output_variable} "${_prik_items}" PARENT_SCOPE) endfunction() +function(_prik_rebase_plan_path output_variable path source_root output_root) + file(RELATIVE_PATH _prik_relative_path "${source_root}" "${path}") + if(_prik_relative_path STREQUAL "") + set(_prik_rebased_path "${output_root}") + elseif(NOT _prik_relative_path MATCHES "^\\.\\.") + set(_prik_rebased_path "${output_root}/${_prik_relative_path}") + else() + set(_prik_rebased_path "${path}") + endif() + set(${output_variable} "${_prik_rebased_path}" PARENT_SCOPE) +endfunction() + function(_prik_rebase_generated_paths output_variable paths source_root output_root) set(_prik_rebased_paths) foreach(_prik_path IN LISTS ${paths}) @@ -79,9 +92,62 @@ function(_prik_rebase_generated_paths output_variable paths source_root output_r set(${output_variable} "${_prik_rebased_paths}" PARENT_SCOPE) endfunction() +function(_prik_apply_compilation_unit target json group index generated source_root output_root) + if(group STREQUAL "native") + set(_prik_unit_path native_build_plan compilation_units) + else() + set(_prik_unit_path generated_compilation_units) + endif() + string(JSON _prik_unit_source GET "${json}" ${_prik_unit_path} ${index} source) + string(JSON _prik_unit_language GET "${json}" ${_prik_unit_path} ${index} language) + if(generated) + _prik_rebase_plan_path( + _prik_unit_source "${_prik_unit_source}" "${source_root}" "${output_root}" + ) + endif() + _prik_json_string_list(_prik_unit_flags "${json}" ${_prik_unit_path} ${index} flags) + _prik_json_string_list(_prik_unit_abi_flags "${json}" ${_prik_unit_path} ${index} abi_flags) + _prik_json_string_list(_prik_unit_include_dirs "${json}" ${_prik_unit_path} ${index} include_dirs) + + set(_prik_rebased_include_dirs) + foreach(_prik_include_dir IN LISTS _prik_unit_include_dirs) + _prik_rebase_plan_path( + _prik_rebased_include_dir "${_prik_include_dir}" "${source_root}" "${output_root}" + ) + list(APPEND _prik_rebased_include_dirs "${_prik_rebased_include_dir}") + endforeach() + if(_prik_unit_flags) + set_property( + SOURCE "${_prik_unit_source}" + APPEND PROPERTY COMPILE_OPTIONS ${_prik_unit_flags} + ) + endif() + if(_prik_unit_language STREQUAL "fortran") + set(_prik_cmake_unit_language Fortran) + else() + set(_prik_cmake_unit_language C) + endif() + foreach(_prik_abi_flag IN LISTS _prik_unit_abi_flags) + set(_prik_abi_key "${_prik_cmake_unit_language}:${_prik_abi_flag}") + get_property(_prik_applied_abi_flags TARGET "${target}" PROPERTY _PRIK_APPLIED_ABI_FLAGS) + if(NOT _prik_abi_key IN_LIST _prik_applied_abi_flags) + target_compile_options( + "${target}" PRIVATE "$<$:${_prik_abi_flag}>" + ) + set_property(TARGET "${target}" APPEND PROPERTY _PRIK_APPLIED_ABI_FLAGS "${_prik_abi_key}") + endif() + endforeach() + if(_prik_rebased_include_dirs) + set_property( + SOURCE "${_prik_unit_source}" + APPEND PROPERTY INCLUDE_DIRECTORIES ${_prik_rebased_include_dirs} + ) + endif() +endfunction() + function(_prik_validate_args args) foreach(_prik_arg IN LISTS ${args}) - if(_prik_arg MATCHES "^--(build-manifest|cmake|json|language|makefile|module-name|native-c-sources|native-fortran-sources|no-compile-input-sources|out|out-dir|plan|pyi|sources)(=|$)") + if(_prik_arg MATCHES "^--(analysis-fortran-compiler|build-manifest|cmake|compiler|json|jobs|language|lto|makefile|module-name|native-c-compile-flags|native-c-sources|native-compile-flags|native-fortran-sources|native-library|native-library-dir|native-link-item|native-linker-language|native-objects|no-compile-input-sources|no-standard-logicals|out|out-dir|plan|pyi|sources|wrapper-c-flags|wrapper-compiler-debug|wrapper-fortran-flags)(=|$)") message(FATAL_ERROR "PRIK_ARGS cannot override prik_add_module build ownership: ${_prik_arg}") endif() endforeach() @@ -95,8 +161,8 @@ function(prik_add_module name) message(FATAL_ERROR "PRIK module target already exists: ${name}") endif() - set(_options NO_COMPILE_INPUT_SOURCES) - set(_one_value_arguments CONTRACT) + set(_options NO_COMPILE_INPUT_SOURCES NO_STANDARD_LOGICALS) + set(_one_value_arguments CONTRACT LINKER_LANGUAGE) set(_multi_value_arguments SOURCES FORTRAN_SOURCES @@ -105,6 +171,8 @@ function(prik_add_module name) MODULE_DIRS FORTRAN_FLAGS C_FLAGS + WRAPPER_FORTRAN_FLAGS + WRAPPER_C_FLAGS LINK_LIBRARIES LINK_OPTIONS PRIK_ARGS @@ -121,8 +189,8 @@ function(prik_add_module name) if(PRIK_NO_COMPILE_INPUT_SOURCES AND NOT PRIK_SOURCES) message(FATAL_ERROR "prik_add_module(${name}) uses NO_COMPILE_INPUT_SOURCES only with SOURCES") endif() - if(PRIK_NO_COMPILE_INPUT_SOURCES AND NOT PRIK_FORTRAN_SOURCES AND NOT PRIK_C_SOURCES) - message(FATAL_ERROR "prik_add_module(${name}) requires native implementation sources") + if(PRIK_NO_COMPILE_INPUT_SOURCES AND NOT PRIK_FORTRAN_SOURCES AND NOT PRIK_C_SOURCES AND NOT PRIK_LINK_LIBRARIES) + message(FATAL_ERROR "prik_add_module(${name}) requires native implementation sources or LINK_LIBRARIES") endif() if(NOT PRIK_CONTRACT AND NOT PRIK_SOURCES AND NOT PRIK_FORTRAN_SOURCES AND NOT PRIK_C_SOURCES) message(FATAL_ERROR "prik_add_module(${name}) requires SOURCES, FORTRAN_SOURCES, C_SOURCES, or CONTRACT") @@ -135,6 +203,13 @@ function(prik_add_module name) _prik_make_absolute_paths(_prik_include_dirs ${PRIK_INCLUDE_DIRS}) _prik_make_absolute_paths(_prik_module_dirs ${PRIK_MODULE_DIRS}) + if(PRIK_LINKER_LANGUAGE) + string(TOLOWER "${PRIK_LINKER_LANGUAGE}" _prik_linker_language) + if(NOT _prik_linker_language STREQUAL "c" AND NOT _prik_linker_language STREQUAL "fortran") + message(FATAL_ERROR "prik_add_module(${name}) LINKER_LANGUAGE must be C or Fortran") + endif() + endif() + if(_prik_contract) set(_prik_wrapper_sources) set(_prik_native_fortran_sources ${_prik_fortran_sources}) @@ -143,8 +218,10 @@ function(prik_add_module name) set(_prik_language fortran) elseif(_prik_native_c_sources) set(_prik_language c) + elseif(PRIK_LINK_LIBRARIES AND _prik_linker_language) + set(_prik_language "${_prik_linker_language}") else() - message(FATAL_ERROR "PRIK contract module ${name} requires native FORTRAN_SOURCES or C_SOURCES") + message(FATAL_ERROR "PRIK contract module ${name} requires native sources, or LINK_LIBRARIES with LINKER_LANGUAGE") endif() else() if(_prik_sources) @@ -198,13 +275,27 @@ function(prik_add_module name) if(PRIK_NO_COMPILE_INPUT_SOURCES) list(APPEND _prik_generate_command --no-compile-input-sources) endif() + if((PRIK_NO_COMPILE_INPUT_SOURCES OR _prik_contract) AND NOT _prik_native_fortran_sources AND NOT _prik_native_c_sources) + list(APPEND _prik_generate_command --external-native-implementation) + endif() list(APPEND _prik_generate_command --module-name "${name}") list(APPEND _prik_generate_command --language "${_prik_language}") - # Later PRIK_ARGS may override this when source analysis requires a - # distinct compiler, but CMake's selected toolchain is the default. + # PRIK analyzes and probes with CMake's selected compiler; CMake retains + # ownership of all actual compilation and linking. list(APPEND _prik_generate_command --compiler "${_prik_analysis_compiler}") + if(CMAKE_Fortran_COMPILER) + list(APPEND _prik_generate_command --analysis-fortran-compiler "${CMAKE_Fortran_COMPILER}") + endif() _prik_append_cli_flags(_prik_generate_command --native-compile-flags PRIK_FORTRAN_FLAGS) _prik_append_cli_flags(_prik_generate_command --native-c-compile-flags PRIK_C_FLAGS) + _prik_append_cli_flags(_prik_generate_command --wrapper-fortran-flags PRIK_WRAPPER_FORTRAN_FLAGS) + _prik_append_cli_flags(_prik_generate_command --wrapper-c-flags PRIK_WRAPPER_C_FLAGS) + if(PRIK_NO_STANDARD_LOGICALS) + list(APPEND _prik_generate_command --no-standard-logicals) + endif() + if(_prik_linker_language) + list(APPEND _prik_generate_command --native-linker-language "${_prik_linker_language}") + endif() foreach(_include_dir IN LISTS _prik_include_dirs _prik_module_dirs) list(APPEND _prik_generate_command -I "${_include_dir}") endforeach() @@ -242,7 +333,24 @@ function(prik_add_module name) if(NOT _prik_planned_outputs) message(FATAL_ERROR "PRIK did not return generated outputs for ${name}") endif() - _prik_json_string_list(_prik_semantic_dependencies "${_prik_plan_json}" sources) + _prik_json_string_list(_prik_semantic_dependencies "${_prik_plan_json}" semantic_dependencies) + _prik_json_string_list(_prik_extension_link_flags "${_prik_plan_json}" extension_link_flags) + + string(JSON _prik_linker_language_type TYPE "${_prik_plan_json}" linker_language) + if(_prik_linker_language_type STREQUAL "STRING") + string(JSON _prik_required_linker_language GET "${_prik_plan_json}" linker_language) + elseif(NOT _prik_linker_language_type STREQUAL "NULL") + message(FATAL_ERROR "PRIK returned an invalid linker_language for ${name}") + endif() + + string( + JSON _prik_generated_unit_count + ERROR_VARIABLE _prik_generated_plan_error + LENGTH "${_prik_plan_json}" generated_compilation_units + ) + if(_prik_generated_plan_error) + message(FATAL_ERROR "PRIK did not return generated compilation units: ${_prik_generated_plan_error}") + endif() string( JSON _prik_native_unit_count @@ -317,8 +425,31 @@ function(prik_add_module name) if(_prik_native_target_sources) target_sources("${name}" PRIVATE ${_prik_native_target_sources}) endif() + if(_prik_generated_unit_count GREATER 0) + math(EXPR _prik_last_generated_unit_index "${_prik_generated_unit_count} - 1") + foreach(_prik_generated_unit_index RANGE 0 ${_prik_last_generated_unit_index}) + _prik_apply_compilation_unit( + "${name}" "${_prik_plan_json}" generated ${_prik_generated_unit_index} TRUE + "${_prik_plan_dir}" "${_prik_output_dir}" + ) + endforeach() + endif() + if(_prik_native_unit_count GREATER 0) + math(EXPR _prik_last_native_unit_index "${_prik_native_unit_count} - 1") + foreach(_prik_native_unit_index RANGE 0 ${_prik_last_native_unit_index}) + _prik_apply_compilation_unit( + "${name}" "${_prik_plan_json}" native ${_prik_native_unit_index} FALSE + "${_prik_plan_dir}" "${_prik_output_dir}" + ) + endforeach() + endif() set_target_properties("${name}" PROPERTIES PREFIX "" OUTPUT_NAME "${name}") - if(_prik_language STREQUAL "fortran" OR _prik_native_fortran_sources) + if(_prik_required_linker_language STREQUAL "fortran") + set_property(TARGET "${name}" PROPERTY LINKER_LANGUAGE Fortran) + elseif(_prik_required_linker_language STREQUAL "c") + set_property(TARGET "${name}" PROPERTY LINKER_LANGUAGE C) + endif() + if(_prik_required_linker_language STREQUAL "fortran") set_target_properties("${name}" PROPERTIES Fortran_MODULE_DIRECTORY "${_prik_output_dir}") endif() @@ -330,16 +461,10 @@ function(prik_add_module name) ${_prik_include_dirs} ${_prik_module_dirs} ) - foreach(_flag IN LISTS PRIK_FORTRAN_FLAGS) - target_compile_options("${name}" PRIVATE "$<$:${_flag}>") - endforeach() - foreach(_flag IN LISTS PRIK_C_FLAGS) - target_compile_options("${name}" PRIVATE "$<$:${_flag}>") - endforeach() if(PRIK_LINK_LIBRARIES) target_link_libraries("${name}" PRIVATE ${PRIK_LINK_LIBRARIES}) endif() - if(PRIK_LINK_OPTIONS) - target_link_options("${name}" PRIVATE ${PRIK_LINK_OPTIONS}) + if(_prik_extension_link_flags OR PRIK_LINK_OPTIONS) + target_link_options("${name}" PRIVATE ${_prik_extension_link_flags} ${PRIK_LINK_OPTIONS}) endif() endfunction() diff --git a/docs/developer/packages/pipeline.md b/docs/developer/packages/pipeline.md index 476269185..80b8e4b84 100644 --- a/docs/developer/packages/pipeline.md +++ b/docs/developer/packages/pipeline.md @@ -86,6 +86,7 @@ prik/pipeline/ `build.py` is the orchestration hub. Its public records describe inputs and results without executing a build: `NativeCompilationUnit`, +`GeneratedCompilationUnit`, `NativePrebuiltArtifact`, `NativeLinkItem`, `NativeBuildPlan`, and `WrapperBuildResult`. Its three public entrypoints are source-first builds, contract-first builds, and replay of a saved contract-build manifest. @@ -114,6 +115,13 @@ source collections stay distinct, a source-free `.pyi` build states its native language instead of deriving it from a compiler or ABI decorator, and prebuilt objects, archives, and libraries stay ordered `NativeLinkItem` records. +Build integrations consume the completed result rather than compiler command +logs. Native and generated compilation units retain their own requested flags, +required ABI flags, and include directories; the result also records every +semantic/preprocessing dependency and the final linker language. This is the +lossless boundary used by `UsePRIK.cmake` for source properties, regeneration, +and linker-driver selection. + `WrapperBuildResult` and saved `.pyi` manifests report each generated native group's kind, language, member keys, and source paths, so zero-source, adapter-only, support-only, and mixed output stay factual across direct builds, diff --git a/docs/user/guide/cmake.md b/docs/user/guide/cmake.md index 7e5cf31fc..b8a7c69be 100644 --- a/docs/user/guide/cmake.md +++ b/docs/user/guide/cmake.md @@ -13,7 +13,9 @@ publication: reviewed Use CMake when its toolchain, dependency targets, and build scheduling should own compilation and linking. PRIK still parses the native inputs, completes wrapper policy, and generates the wrapper and any Fortran bridge sources. By -default, PRIK uses CMake's selected C or Fortran compiler for source analysis. +default, PRIK uses CMake's selected C or Fortran compiler for preprocessing, +source analysis, and ABI probes. CMake mode does not accept a separate +`--compiler` override because the analyzed and compiled toolchains must agree. ## Existing CMake project @@ -62,13 +64,41 @@ cmake --build build `prik_add_module()` also accepts `SOURCES` for source-first input, `CONTRACT` with `FORTRAN_SOURCES` or `C_SOURCES` for an authored semantic `.pyi`, -`INCLUDE_DIRS`, `MODULE_DIRS`, language-specific compile flags, -`LINK_LIBRARIES`, `LINK_OPTIONS`, and additional `PRIK_ARGS`. Use -`NO_COMPILE_INPUT_SOURCES` when `SOURCES` supplies only the public interface -and `FORTRAN_SOURCES` or `C_SOURCES` supplies its implementation. The -generated wrapper sources are custom-command outputs. Changing a semantic -source or contract regenerates them before CMake compiles the target; CMake -recompiles contract-first native implementations independently. +`INCLUDE_DIRS`, `MODULE_DIRS`, source-specific compile flags, +`LINK_LIBRARIES`, `LINK_OPTIONS`, and additional generation-only `PRIK_ARGS`. +The flag groups remain separate: + +- `FORTRAN_FLAGS` and `C_FLAGS` apply only to user-owned native sources. +- `WRAPPER_FORTRAN_FLAGS` applies only to generated Fortran bridge sources. +- `WRAPPER_C_FLAGS` applies to generated C sources and the extension link, + matching PRIK's normal build behavior. + +PRIK adds compiler-profile flags required by its ABI plan to the affected +Fortran sources. `NO_STANDARD_LOGICALS` disables PRIK's Intel/NVIDIA logical +interoperability option when compatibility with prebuilt objects requires it. +CMake build type, debug, and interprocedural-optimization settings remain +normal CMake target properties; `PRIK_ARGS` rejects compiler and compilation +options that would bypass those target settings. + +Use `NO_COMPILE_INPUT_SOURCES` when `SOURCES` supplies only the public +interface. Its implementation may come from `FORTRAN_SOURCES`, `C_SOURCES`, a +prebuilt library, or a target in `LINK_LIBRARIES`: + +```cmake +add_library(native_math STATIC implementation.f90) + +prik_add_module( + python_api + SOURCES interface.f90 + NO_COMPILE_INPUT_SOURCES + LINK_LIBRARIES native_math +) +``` + +The generated wrapper sources are custom-command outputs. Changing a semantic +source, contract, included C header, or Fortran `INCLUDE` file regenerates them +before CMake compiles the target. CMake recompiles contract-first native +implementations independently. External dependencies remain CMake dependencies. For example, CMake can find BLAS and pass its target to the PRIK extension: @@ -85,6 +115,10 @@ prik_add_module( The same form accepts normal project targets such as `native_math` and `OpenMP::OpenMP_Fortran`; they remain target-oriented CMake link inputs. +Normal Fortran sources and targets carry their link-language requirements +through CMake. For a raw archive or shared library whose language is otherwise +opaque, add `LINKER_LANGUAGE Fortran`; PRIK records that requirement in its +plan and the extension uses CMake's Fortran linker driver. ## Standalone generated project @@ -107,4 +141,5 @@ python3 -m prik generate --cmake contracts/solver.pyi \ The generated `CMakeLists.txt` loads `UsePRIK.cmake` and calls `prik_add_module()`. `UsePRIK.cmake` integrates PRIK into an existing CMake project; `prik generate --cmake` creates a standalone CMake project that uses -that same helper. +that same helper. `--native-linker-language fortran` emits the explicit raw +library annotation when standalone input requires the Fortran linker. diff --git a/docs/user/reference/cli-commands.md b/docs/user/reference/cli-commands.md index 27705fb6e..eb01a116e 100644 --- a/docs/user/reference/cli-commands.md +++ b/docs/user/reference/cli-commands.md @@ -98,6 +98,7 @@ least one explicit native input: `--native-fortran-sources`, `--native-c-sources | `--native-objects PATH ...` | Links object files, static archives, or shared libraries. | | `--native-library NAME ...` | Links system libraries by name — `--native-library openblas` passes `-lopenblas`. | | `--native-link-item KIND:VALUE ...` | Ordered link items. `KIND` is `object`, `archive`, `shared-library`, `library`, or `arg`. | +| `--native-linker-language {c,fortran}` | Requires the named final linker language when prebuilt inputs do not carry it. | | `--native-library-dir DIR ...` | Library search directories and runtime paths. | | `--lto` | Enables link-time optimization for Fortran and C builds by adding `-flto` to generated and native compilation and to the extension link. | | `--collision-adapter NAME ...` | Calls native symbol `NAME` through a forwarder defined in a separate translation unit, so the binding never declares an identifier its own headers already declare. | @@ -226,12 +227,18 @@ python3 -m prik generate --pyi --language c path/to/api.c --out api.pyi a valid wrapper plan; they skip object compilation and linking, and use `--out-dir`. `--cmake` writes the CMake project; its CMake configuration later runs PRIK's wrapper-generation step, while CMake owns compilation and linking. +In CMake mode, `--compiler` and `--wrapper-compiler-debug` are rejected: +CMake's selected compiler and build configuration own those choices. Native +and generated-wrapper flag options remain distinct in the generated helper +call, `--no-standard-logicals` maps to PRIK's CMake compilation plan, and +`--lto` maps to CMake's interprocedural-optimization target property. With no `--out`, `generate --pyi` prints every generated contract. For Fortran, `--out PATH` names a package directory containing `__init__.pyi` and any module leaves. For C, it names the single output `.pyi` file. Bare `--out` writes beside the inputs. The [source-to-contract layouts](pyi-format.md#source-to-contract-layout) show both forms. -`--compiler` and `-I` affect only preprocessing and datatype measurement. +Outside CMake mode, `--compiler` and `-I` affect preprocessing and datatype +measurement as documented by the selected command. In `.pyi` Makefile mode, PRIK writes `/prik-build.json` first, then generates `/Makefile.prik` from that manifest. diff --git a/docs/user/reference/configuration-files.md b/docs/user/reference/configuration-files.md index 72165903a..a24f7e5da 100644 --- a/docs/user/reference/configuration-files.md +++ b/docs/user/reference/configuration-files.md @@ -92,6 +92,17 @@ A representative manifest has this structure: "requested_name": null }, "generated_wrapper": { + "compilation_units": [ + { + "abi_flags": [], + "flags": [], + "include_dirs": [ + "." + ], + "language": "c", + "source": "module_wrapper.c" + } + ], "native_code_groups": [], "sources": [ "module_wrapper.c" @@ -105,6 +116,7 @@ A representative manifest has this structure: "native_build_plan": { "compilation_units": [ { + "abi_flags": [], "flags": [], "include_dirs": [], "language": "fortran", @@ -121,6 +133,7 @@ A representative manifest has this structure: "path": "module.o" } ], + "linker_language": null, "module_dirs": [ "." ], @@ -134,18 +147,21 @@ A representative manifest has this structure: "shared_library": "module.cpython-.so", "strict_wrapper_names": false }, - "schema_version": 4 + "schema_version": 5 } ``` The values and array contents vary by build. In particular, -`generated_wrapper.native_code_groups` records any generated Fortran adapters -or support sources, while `native_build_plan.link_items` preserves the exact -order of objects, archives, shared libraries, named libraries, and linker -arguments. Paths are stored relative to the manifest directory when possible -and resolved from that directory during replay. - -Replay reads the current schema version, `4`. Regenerate the manifest with the +`generated_wrapper.compilation_units` and +`native_build_plan.compilation_units` keep source-specific flags, required ABI +flags, and include directories. `generated_wrapper.native_code_groups` records +any generated Fortran adapters or support sources, while +`native_build_plan.link_items` preserves the exact order of objects, archives, +shared libraries, named libraries, and linker arguments. Paths are stored +relative to the manifest directory when possible and resolved from that +directory during replay. + +Replay reads the current schema version, `5`. Regenerate the manifest with the current PRIK version when upgrading from an older schema. ## `Makefile.prik` diff --git a/docs/user/reference/python-api.md b/docs/user/reference/python-api.md index 415ed1f76..25b0149eb 100644 --- a/docs/user/reference/python-api.md +++ b/docs/user/reference/python-api.md @@ -97,7 +97,7 @@ Reach past the root facade when you need a single stage rather than a build. | Semantic conversion | `prik.semantics.fortran2ir`, `prik.semantics.pyi2ir` | Fortran conversion helpers, `convert_pyi_to_ir` | | C semantic conversion | `prik.semantics.c2ir` | `CToIRConverter`, `c_file_to_semantic_module`, `c_file_to_semantic_modules` | | `.pyi` loading and stub emission | `prik.pipeline.pyi` | `pyi_*_to_semantic_module`, `emit_module_stubs` | -| Build records and results | `prik.pipeline.build` | `WrapperBuildResult`, `NativeBuildPlan`, `NativeCompilationUnit`, `NativePrebuiltArtifact`, `NativeLinkItem` | +| Build records and results | `prik.pipeline.build` | `WrapperBuildResult`, `NativeBuildPlan`, `NativeCompilationUnit`, `GeneratedCompilationUnit`, `NativePrebuiltArtifact`, `NativeLinkItem` | | IPython/Jupyter integration | `prik.jupyter` | `%load_ext prik.jupyter`, then `%%fortran`, `%%c`, or `%%pyi` | | Target type probing | `prik.preprocessing.probes.fortran_types` | probe source, requirements, expressions, report and error types | | C target type probing | `prik.preprocessing.probes.c_types` | `probe_c_standard_types`, `probe_c_standard_types_cached`, and C probe records/error type | diff --git a/prik/cli.py b/prik/cli.py index 35cf88e20..86cc92a31 100644 --- a/prik/cli.py +++ b/prik/cli.py @@ -936,6 +936,7 @@ def _native_link_options_used(args: argparse.Namespace) -> bool: or getattr(args, "native_libraries", None) or getattr(args, "native_link_items", None) or getattr(args, "native_library_dirs", None) + or getattr(args, "native_linker_language", None) ) @@ -985,7 +986,7 @@ def _validate_pyi_wrapper_options(args: argparse.Namespace, parser: argparse.Arg "--export-symbols selects declarations while reading C source; a semantic .pyi contract " "already states its public functions" ) - if not ( + if not getattr(args, "external_native_implementation", False) and not ( getattr(args, "native_fortran_sources", None) or getattr(args, "native_c_sources", None) or getattr(args, "native_objects", None) @@ -1048,7 +1049,7 @@ def _validate_source_wrapper_options(args: argparse.Namespace, parser: argparse. parser.error(f"A wrapper build found no recognized {label} sources under: {empty_directories[0]}") if not getattr(args, "no_compile_input_sources", False): return - if not ( + if not getattr(args, "external_native_implementation", False) and not ( getattr(args, "native_fortran_sources", None) or getattr(args, "native_c_sources", None) or _prebuilt_native_link_input_used(args) @@ -1078,6 +1079,11 @@ def _validate_wrapper_build_options(args: argparse.Namespace, parser: argparse.A parser.error("generate --sources/--makefile/--cmake uses --out-dir, not --out") if args.command == "generate" and getattr(args, "module_name", None) is not None: _validate_wrapper_out(argparse.Namespace(out=args.module_name), parser) + if getattr(args, "cmake", False): + if getattr(args, "compiler", None): + parser.error("generate --cmake uses CMake's selected compiler; do not pass --compiler") + if getattr(args, "wrapper_compiler_debug", False): + parser.error("generate --cmake does not accept --wrapper-compiler-debug; use CMake build types") if getattr(args, "plan_only", False) and not (args.command == "generate" and args.generate_sources): parser.error("--plan requires generate --sources") if args.command == "build": @@ -1469,7 +1475,11 @@ def record_total_build_time(elapsed: float) -> None: if _wrapper_build_uses_pyi_contract(args): result = build_pyi_extension( args.paths[0], - input_compiler=preprocessing.compiler or "gfortran", + input_compiler=( + getattr(args, "analysis_fortran_compiler", None) + or (preprocessing.compiler if args.language == "fortran" else None) + or "gfortran" + ), input_c_compiler=(preprocessing.compiler or "cc") if args.language == "c" else None, native_language=args.language, native_fortran_sources=getattr(args, "native_fortran_sources", None), @@ -1485,6 +1495,7 @@ def record_total_build_time(elapsed: float) -> None: native_link_items=_cli_native_link_items(getattr(args, "native_link_items", None)), native_library_dirs=getattr(args, "native_library_dirs", None), native_include_dirs=_cli_build_include_dirs(args), + native_linker_language=getattr(args, "native_linker_language", None), output_name=_wrapper_output_name(args), output_dir=getattr(args, "out_dir", None), strict_wrapper_names=getattr(args, "strict_wrapper_names", False), @@ -1494,6 +1505,7 @@ def record_total_build_time(elapsed: float) -> None: makefile=getattr(args, "makefile", False), generate_sources=getattr(args, "generate_sources", False), _plan_only=getattr(args, "plan_only", False), + _external_native_implementation=getattr(args, "external_native_implementation", False), jobs=getattr(args, "jobs", None), standard_logicals=getattr(args, "standard_logicals", True), verbose=1 if getattr(args, "verbose", False) else 0, @@ -1516,7 +1528,8 @@ def record_total_build_time(elapsed: float) -> None: input_c_compiler=getattr(args, "compiler", None), preprocessing=preprocessing, export_symbols=getattr(args, "_resolved_export_symbols", None), - input_compiler="gfortran", + input_compiler=getattr(args, "analysis_fortran_compiler", None) or "gfortran", + compile_input_sources=not getattr(args, "no_compile_input_sources", False), native_c_sources=getattr(args, "native_c_sources", None), native_c_flags=_with_link_time_optimization( _cli_native_c_compile_flags(getattr(args, "native_c_compile_flags", None)), args @@ -1530,6 +1543,7 @@ def record_total_build_time(elapsed: float) -> None: native_link_items=_cli_native_link_items(getattr(args, "native_link_items", None)), native_library_dirs=getattr(args, "native_library_dirs", None), native_include_dirs=_cli_build_include_dirs(args), + native_linker_language=getattr(args, "native_linker_language", None), strict_wrapper_names=getattr(args, "strict_wrapper_names", False), collision_adapters=getattr(args, "collision_adapters", None), collision_adapter_all=getattr(args, "collision_adapter_all", False), @@ -1537,6 +1551,7 @@ def record_total_build_time(elapsed: float) -> None: makefile=getattr(args, "makefile", False), generate_sources=getattr(args, "generate_sources", False), _plan_only=getattr(args, "plan_only", False), + _external_native_implementation=getattr(args, "external_native_implementation", False), jobs=getattr(args, "jobs", None), verbose=1 if getattr(args, "verbose", False) else 0, wrapper_compiler_debug=getattr(args, "wrapper_compiler_debug", False), @@ -1576,9 +1591,11 @@ def record_total_build_time(elapsed: float) -> None: native_link_items=_cli_native_link_items(getattr(args, "native_link_items", None)), native_library_dirs=getattr(args, "native_library_dirs", None), native_include_dirs=_cli_build_include_dirs(args), + native_linker_language=getattr(args, "native_linker_language", None), makefile=getattr(args, "makefile", False), generate_sources=getattr(args, "generate_sources", False), _plan_only=getattr(args, "plan_only", False), + _external_native_implementation=getattr(args, "external_native_implementation", False), jobs=getattr(args, "jobs", None), verbose=1 if getattr(args, "verbose", False) else 0, wrapper_compiler_debug=getattr(args, "wrapper_compiler_debug", False), @@ -2306,6 +2323,11 @@ def _add_native_compilation_options(group: argparse._ArgumentGroup) -> None: def _add_extension_link_options(group: argparse._ArgumentGroup) -> None: + group.add_argument( + "--native-linker-language", + choices=("c", "fortran"), + help="Required final linker language for opaque native inputs", + ) group.add_argument( "--native-objects", dest="native_objects", @@ -2418,6 +2440,9 @@ def _add_diagnostic_controls(group: argparse._ArgumentGroup, *, allow_verbose: b "native_libraries": None, "native_link_items": None, "native_library_dirs": None, + "native_linker_language": None, + "external_native_implementation": False, + "analysis_fortran_compiler": None, "strict_wrapper_names": False, "lto": False, "collision_adapters": None, @@ -2820,6 +2845,17 @@ def _generate_parser(argv: list[str]) -> argparse.ArgumentParser: help="Python module name for generated wrapper sources", ) output_group.add_argument("--plan", dest="plan_only", action="store_true", help=argparse.SUPPRESS) + output_group.add_argument( + "--external-native-implementation", + dest="external_native_implementation", + action="store_true", + help=argparse.SUPPRESS, + ) + output_group.add_argument( + "--analysis-fortran-compiler", + dest="analysis_fortran_compiler", + help=argparse.SUPPRESS, + ) diagnostic_group = parser.add_argument_group("diagnostic options") _add_diagnostic_controls(diagnostic_group) return parser diff --git a/prik/cmake.py b/prik/cmake.py index 25bf74922..4cb718ae0 100644 --- a/prik/cmake.py +++ b/prik/cmake.py @@ -103,8 +103,6 @@ def add_option(option: str, value: object | None = None) -> None: else: result.append(f"{option}={value}") - if args.compiler: - add_option("--compiler", args.compiler) for option, values in ( ("--define", args.defines), ("--undef", args.undefs), @@ -124,20 +122,14 @@ def add_option(option: str, value: object | None = None) -> None: add_option(option, value) if args.include_exposure != "reachable-project": add_option("--include-exposure", args.include_exposure) - for option, values in ( - ("--wrapper-fortran-flags", args.wrapper_fortran_flags), - ("--wrapper-c-flags", args.wrapper_c_flags), - ("--collision-adapter", args.collision_adapters), - ): + for option, values in (("--collision-adapter", args.collision_adapters),): if values: result.extend(f"{option}={value}" for value in values) for option, enabled in ( ("--strict-wrapper-names", args.strict_wrapper_names), ("--assume-intent-in-scalars", args.assume_intent_in_scalars), - ("--wrapper-compiler-debug", args.wrapper_compiler_debug), ("--collision-adapter-all", args.collision_adapter_all), ("--positional-only", args.positional_only), - ("--no-standard-logicals", not args.standard_logicals), ): if enabled: add_option(option) @@ -190,8 +182,11 @@ def _module_inputs(*, paths: Iterable[str | Path], args) -> _CMakeModuleInputs: native_fortran = _absolute_paths(args.native_fortran_sources or ()) native_c = _absolute_paths(args.native_c_sources or ()) - if args.no_compile_input_sources and not native_fortran and not native_c: - raise ValueError("generate --cmake --no-compile-input-sources requires native implementation sources") + has_link_implementation = bool(args.native_objects or args.native_libraries or args.native_link_items) + if args.no_compile_input_sources and not native_fortran and not native_c and not has_link_implementation: + raise ValueError( + "generate --cmake --no-compile-input-sources requires native implementation sources or libraries" + ) return _CMakeModuleInputs( module_name=module_name, semantic_sources=input_paths, @@ -261,17 +256,21 @@ def _append_build_options( native_link_items: Iterable[dict[str, object]], ) -> None: _append_block(lines, "INCLUDE_DIRS", _absolute_paths(args.include_dirs or ()), base=project_dir) - fortran_flags = (*_flag_values(args.native_compile_flags), *_flag_values(args.wrapper_fortran_flags)) - c_flags = (*_flag_values(args.native_c_compile_flags), *_flag_values(args.wrapper_c_flags)) + fortran_flags = _flag_values(args.native_compile_flags) + c_flags = _flag_values(args.native_c_compile_flags) + wrapper_fortran_flags = _flag_values(args.wrapper_fortran_flags) + wrapper_c_flags = _flag_values(args.wrapper_c_flags) libraries, link_options = _link_values(args, base=project_dir, native_link_items=native_link_items) - if args.lto: - fortran_flags = (*fortran_flags, "-flto") - c_flags = (*c_flags, "-flto") - link_options = (*link_options, "-flto") _append_values(lines, "FORTRAN_FLAGS", fortran_flags) _append_values(lines, "C_FLAGS", c_flags) + _append_values(lines, "WRAPPER_FORTRAN_FLAGS", wrapper_fortran_flags) + _append_values(lines, "WRAPPER_C_FLAGS", wrapper_c_flags) _append_values(lines, "LINK_LIBRARIES", libraries) _append_values(lines, "LINK_OPTIONS", link_options) + if args.native_linker_language: + lines.append(f" LINKER_LANGUAGE {args.native_linker_language.capitalize()}") + if not args.standard_logicals: + lines.append(" NO_STANDARD_LOGICALS") _append_values(lines, "PRIK_ARGS", _prik_args(args)) @@ -288,7 +287,11 @@ def write_cmake_project( project_dir.mkdir(parents=True, exist_ok=True) inputs = _module_inputs(paths=paths, args=args) cmake_module_dir() - project_languages = "C Fortran" if language == "fortran" or inputs.native_fortran else "C" + project_languages = ( + "C Fortran" + if language == "fortran" or inputs.native_fortran or args.native_linker_language == "fortran" + else "C" + ) lines = _project_preamble(module_name=inputs.module_name, languages=project_languages) _append_source_declarations( lines, @@ -299,6 +302,8 @@ def write_cmake_project( ) _append_build_options(lines, args=args, project_dir=project_dir, native_link_items=native_link_items) lines.append(")") + if args.lto: + lines.extend(("", f"set_property(TARGET {inputs.module_name} PROPERTY INTERPROCEDURAL_OPTIMIZATION TRUE)")) cmake_lists = project_dir / "CMakeLists.txt" cmake_lists.write_text("\n".join(lines) + "\n", encoding="utf-8") diff --git a/prik/compiler/compilers.py b/prik/compiler/compilers.py index 46c5bc5e4..c65d111eb 100644 --- a/prik/compiler/compilers.py +++ b/prik/compiler/compilers.py @@ -204,6 +204,18 @@ def resolved_executable(self, language: str) -> str: """Return the resolved compiler executable selected for ``language``.""" return self._executable(self._language(language), ()) + def required_abi_flags(self, language: str) -> tuple[str, ...]: + """Return compiler-profile flags required by PRIK's selected ABI. + + These are separate from optimization, debug, position-independent-code, + and caller flags so an external build system can preserve PRIK's ABI + policy while continuing to own its normal toolchain configuration. + """ + configuration = self._language(language) + if language != "fortran" or not self._standard_logicals: + return () + return self._strings(configuration.get("logical_interop_flags", ())) + def compile_object(self, object_file: ObjectFile, *, verbose: bool | int = False) -> tuple[str, ...]: """Compile exactly one source file into its declared object path.""" diff --git a/prik/pipeline/build.py b/prik/pipeline/build.py index b3287f3ab..515bda8e3 100644 --- a/prik/pipeline/build.py +++ b/prik/pipeline/build.py @@ -83,7 +83,7 @@ _DEFAULT_BUILD_DIR_NAME = "__prik__" _BUILD_MANIFEST_NAME = "prik-build.json" -_BUILD_MANIFEST_SCHEMA_VERSION = 4 +_BUILD_MANIFEST_SCHEMA_VERSION = 5 _FORTRAN_SOURCE_SUFFIXES = {".f", ".f03", ".f08", ".f77", ".f90", ".f95", ".for", ".ftn"} _C_SOURCE_SUFFIXES = {".c"} _NATIVE_PATH_LINK_KINDS = frozenset({"object", "archive", "shared_library"}) @@ -200,9 +200,9 @@ class NativeCompilationUnit: Object path produced in the build directory. language Compiler language selected for ``source``. - module_dir, include_dirs, flags + module_dir, include_dirs, flags, abi_flags Module-output location, header/module search paths, and per-source - compiler flags recorded for reproducible builds. + caller flags plus compiler-policy flags required by PRIK's ABI. """ source: Path @@ -211,6 +211,7 @@ class NativeCompilationUnit: module_dir: Path | None = None include_dirs: tuple[Path, ...] = () flags: tuple[str, ...] = () + abi_flags: tuple[str, ...] = () def __post_init__(self) -> None: """Normalize path and flag fields after dataclass construction. @@ -225,6 +226,7 @@ def __post_init__(self) -> None: object.__setattr__(self, "module_dir", Path(self.module_dir)) object.__setattr__(self, "include_dirs", tuple(Path(path) for path in self.include_dirs)) object.__setattr__(self, "flags", tuple(str(flag) for flag in self.flags)) + object.__setattr__(self, "abi_flags", tuple(str(flag) for flag in self.abi_flags)) def to_dict(self) -> dict[str, object]: """Return a JSON-ready representation of this compilation unit. @@ -239,6 +241,38 @@ def to_dict(self) -> dict[str, object]: "module_dir": str(self.module_dir) if self.module_dir is not None else None, "include_dirs": [str(path) for path in self.include_dirs], "flags": list(self.flags), + "abi_flags": list(self.abi_flags), + } + + +@dataclass(frozen=True) +class GeneratedCompilationUnit: + """Describe one generated bridge or binding source compilation. + + External build integrations consume this completed record instead of + reconstructing wrapper-language, flag, include, or ABI requirements. + """ + + source: Path + language: str + include_dirs: tuple[Path, ...] = () + flags: tuple[str, ...] = () + abi_flags: tuple[str, ...] = () + + def __post_init__(self) -> None: + object.__setattr__(self, "source", Path(self.source)) + object.__setattr__(self, "include_dirs", tuple(Path(path) for path in self.include_dirs)) + object.__setattr__(self, "flags", tuple(str(flag) for flag in self.flags)) + object.__setattr__(self, "abi_flags", tuple(str(flag) for flag in self.abi_flags)) + + def to_dict(self) -> dict[str, object]: + """Return this generated compilation unit in JSON-ready form.""" + return { + "source": str(self.source), + "language": self.language, + "include_dirs": [str(path) for path in self.include_dirs], + "flags": list(self.flags), + "abi_flags": list(self.abi_flags), } @@ -353,6 +387,7 @@ class NativeBuildPlan: include_dirs: tuple[Path, ...] = () library_dirs: tuple[Path, ...] = () link_items: tuple[NativeLinkItem, ...] = () + linker_language: str | None = None def __post_init__(self) -> None: """Normalize every collection and filesystem field in this plan. @@ -367,6 +402,8 @@ def __post_init__(self) -> None: object.__setattr__(self, "include_dirs", tuple(Path(path) for path in self.include_dirs)) object.__setattr__(self, "library_dirs", tuple(Path(path) for path in self.library_dirs)) object.__setattr__(self, "link_items", tuple(self.link_items)) + if self.linker_language not in {None, "c", "fortran"}: + raise ValueError("Native build-plan linker language must be 'c', 'fortran', or None") def to_dict(self) -> dict[str, object]: """Return a complete JSON-ready snapshot of the native build plan.""" @@ -378,6 +415,7 @@ def to_dict(self) -> dict[str, object]: "include_dirs": [str(path) for path in self.include_dirs], "library_dirs": [str(path) for path in self.library_dirs], "link_items": [item.to_dict() for item in self.link_items], + "linker_language": self.linker_language, } @@ -406,6 +444,10 @@ class WrapperBuildResult: build_manifest: Path | None = None manifest: dict[str, object] | None = None native_generated_code_groups: tuple[NativeGeneratedCodeGroupPlan, ...] = () + generated_compilation_units: tuple[GeneratedCompilationUnit, ...] = () + semantic_dependencies: tuple[Path, ...] = () + linker_language: str | None = None + extension_link_flags: tuple[str, ...] = () def import_module(self) -> ModuleType: """Import and return this result's built extension module. @@ -468,6 +510,10 @@ def to_dict(self) -> dict[str, object]: "generated_sources": [str(path) for path in self.generated_sources], "generated_files": [str(path) for path in self.generated_files], "native_build_plan": self.native_build_plan.to_dict(), + "generated_compilation_units": [unit.to_dict() for unit in self.generated_compilation_units], + "semantic_dependencies": [str(path) for path in self.semantic_dependencies], + "linker_language": self.linker_language, + "extension_link_flags": list(self.extension_link_flags), "build_manifest": str(self.build_manifest) if self.build_manifest is not None else None, "manifest": self.manifest, "native_generated_code_groups": [ @@ -566,6 +612,34 @@ def _parse_c_wrapper_source(path: Path, preprocessing: PreprocessingConfig): return parsed +def _semantic_dependency_paths( + root: Path, + included_files: Iterable[object], +) -> tuple[Path, ...]: + """Return existing root and transitive preprocessing inputs in stable order.""" + dependencies = [root.resolve(strict=False)] + for item in included_files: + raw_path = item.get("path") if isinstance(item, Mapping) else getattr(item, "path", None) + if not isinstance(raw_path, str | Path) or str(raw_path).startswith("<"): + continue + path = Path(raw_path) + if not path.is_absolute(): + path = root.parent / path + path = path.resolve(strict=False) + if path.is_file(): + dependencies.append(path) + return _unique_paths(dependencies) + + +def _c_wrapper_semantic_dependencies(parsed_sources, source_paths: tuple[Path, ...]) -> tuple[Path, ...]: + """Collect source and included-header dependencies recorded by C preprocessing.""" + dependencies = [] + for parsed, source_path in zip(parsed_sources, source_paths, strict=True): + recipe = parsed.preprocessing_recipe or {} + dependencies.extend(_semantic_dependency_paths(source_path, recipe.get("included_files") or ())) + return _unique_paths(dependencies) + + def _reject_unmodeled_c_declarations(parsed, path: Path) -> None: """Raise when the C parser could not model a declaration written in ``path``. @@ -603,6 +677,17 @@ def _fortran_source_for_pipeline(path: Path, preprocessing: PreprocessingConfig) return path.read_text(encoding="utf-8") +def _fortran_source_and_dependencies( + path: Path, + preprocessing: PreprocessingConfig, +) -> tuple[str, tuple[Path, ...]]: + """Preprocess one wrapper source and retain every interface dependency.""" + if preprocessing.uses_compiler: + result = preprocess_source(path, language="fortran", config=preprocessing) + return result.source, _semantic_dependency_paths(path, result.included_files) + return path.read_text(encoding="utf-8"), _semantic_dependency_paths(path, ()) + + def _compiler_flags(flags: Iterable[str] | None) -> tuple[str, ...]: """Normalize optional caller compiler flags into an immutable tuple. @@ -898,6 +983,24 @@ def _generated_wrapper_object_stages( return bridge_objects, binding_objects +def _generated_compilation_units( + objects: Iterable[ObjectFile], + *, + compiler: Compiler, +) -> tuple[GeneratedCompilationUnit, ...]: + """Expose completed generated-source compile requirements to build tools.""" + return tuple( + GeneratedCompilationUnit( + source=object_file.source, + language=object_file.language, + include_dirs=tuple(object_file.include_dirs), + flags=tuple(object_file.flags), + abi_flags=compiler.required_abi_flags(object_file.language), + ) + for object_file in objects + ) + + def _generated_wrapper_link_language( bridge_objects: tuple[ObjectFile, ...], binding_objects: tuple[ObjectFile, ...], @@ -924,6 +1027,7 @@ def _native_plan_link_languages(plan: NativeBuildPlan) -> tuple[str, ...]: return tuple( dict.fromkeys( ( + *((plan.linker_language,) if plan.linker_language is not None else ()), *(unit.language for unit in plan.compilation_units), *(artifact.language for artifact in plan.prebuilt_artifacts if artifact.language is not None), *(item.language for item in plan.link_items if item.language is not None), @@ -1084,6 +1188,7 @@ def _build_generated_wrapper_extension( output_dir: str | Path, shared_library_output_dir: str | Path | None = None, sources: Iterable[str | Path] = (), + semantic_dependencies: Iterable[str | Path] = (), native_build_plan: NativeBuildPlan | None = None, native_dependencies: Iterable[ObjectFile] = (), native_compile_batches: Iterable[Iterable[ObjectFile]] = (), @@ -1107,6 +1212,34 @@ def _build_generated_wrapper_extension( output_path = Path(output_dir) shared_output_path = Path(shared_library_output_dir) if shared_library_output_dir is not None else output_path resolved_native_build_plan = native_build_plan or NativeBuildPlan() + native_dependencies = tuple(native_dependencies) + compiler = compiler or _new_compiler() + bridge_objects, binding_objects = _generated_wrapper_object_stages( + rendered, + output_path, + wrapper_fortran_flags=_compiler_flags(wrapper_fortran_flags), + wrapper_c_flags=_compiler_flags(wrapper_c_flags), + native_module_dirs=_unique_paths( + ( + *resolved_native_build_plan.module_dirs, + *resolved_native_build_plan.include_dirs, + ) + ), + ) + generated_compilation_units = _generated_compilation_units( + (*bridge_objects, *binding_objects), + compiler=compiler, + ) + linker_language = _generated_wrapper_link_language( + bridge_objects, + binding_objects, + native_objects=native_dependencies, + required_languages=( + *rendered.required_link_languages, + *_native_plan_link_languages(resolved_native_build_plan), + ), + ) + extension_link_flags = _compiler_flags(wrapper_c_flags) if _plan_only: generated_sources = tuple(_generated_source_output_path(output_path, path) for path in rendered.generated_files) native_support_imports = _generated_wrapper_native_support_imports(rendered.native_support_keys) @@ -1127,6 +1260,10 @@ def _build_generated_wrapper_extension( generated_files=(*generated_sources, *native_support_files), native_build_plan=resolved_native_build_plan, native_generated_code_groups=rendered.native_generated_code_groups, + generated_compilation_units=generated_compilation_units, + semantic_dependencies=tuple(Path(path) for path in semantic_dependencies), + linker_language=linker_language, + extension_link_flags=extension_link_flags, ) output_path.mkdir(parents=True, exist_ok=True) @@ -1134,19 +1271,6 @@ def _build_generated_wrapper_extension( _write_generated_wrapper_sources(rendered, output_path, verbose=verbose) # Prepare generated-object inputs and their native support files. - compiler = compiler or _new_compiler() - bridge_objects, binding_objects = _generated_wrapper_object_stages( - rendered, - output_path, - wrapper_fortran_flags=_compiler_flags(wrapper_fortran_flags), - wrapper_c_flags=_compiler_flags(wrapper_c_flags), - native_module_dirs=_unique_paths( - ( - *resolved_native_build_plan.module_dirs, - *resolved_native_build_plan.include_dirs, - ) - ), - ) native_support_imports = _generated_wrapper_native_support_imports(rendered.native_support_keys) install_native_support( native_support_imports, @@ -1169,19 +1293,11 @@ def _build_generated_wrapper_extension( shared_library = compiler.link_extension( module_name=rendered.module_name, output_dir=shared_output_path, - language=_generated_wrapper_link_language( - bridge_objects, - binding_objects, - native_objects=tuple(native_dependencies), - required_languages=( - *rendered.required_link_languages, - *_native_plan_link_languages(resolved_native_build_plan), - ), - ), - objects=(*tuple(native_dependencies), *bridge_objects, *binding_objects), + language=linker_language, + objects=(*native_dependencies, *bridge_objects, *binding_objects), link_args=tuple(native_link_args), library_dirs=resolved_native_build_plan.library_dirs, - flags=_compiler_flags(wrapper_c_flags), + flags=extension_link_flags, verbose=verbose, ) _print_verbose_timing(verbose, time.perf_counter() - linking_started) @@ -1198,13 +1314,17 @@ def _build_generated_wrapper_extension( compiled=True, generated_sources=generated_sources, generated_files=_expected_generated_files( - source_objects=tuple(native_dependencies), + source_objects=native_dependencies, output_dir=output_path, module_name=rendered.module_name, shared_library=shared_library, ), native_build_plan=resolved_native_build_plan, native_generated_code_groups=rendered.native_generated_code_groups, + generated_compilation_units=generated_compilation_units, + semantic_dependencies=tuple(Path(path) for path in semantic_dependencies), + linker_language=linker_language, + extension_link_flags=extension_link_flags, ) @@ -1647,6 +1767,7 @@ class _NativeBuildInputs: link_item_paths: tuple[Path, ...] library_dirs: tuple[Path, ...] explicit_include_dirs: tuple[Path, ...] + linker_language: str | None # Semantic `.pyi` contract loading and export projection @@ -2105,6 +2226,8 @@ def _native_build_plan( explicit_include_dirs: tuple[Path, ...], include_dirs: tuple[Path, ...], module_dir: Path | None, + compiler: Compiler, + linker_language: str | None, ) -> NativeBuildPlan: """Assemble the ordered native compile and link plan for one extension. @@ -2140,6 +2263,7 @@ def _native_build_plan( module_dir=module_dir if source_object.language == "fortran" else None, include_dirs=include_dirs, flags=tuple(source_object.flags), + abi_flags=compiler.required_abi_flags(source_object.language), ) for source_path, source_object in zip(source_paths, source_objects, strict=True) ), @@ -2149,6 +2273,7 @@ def _native_build_plan( include_dirs=include_dirs, library_dirs=library_dirs, link_items=link_items, + linker_language=linker_language, ) @@ -2255,6 +2380,8 @@ def _native_build_inputs( complete_native_link_items: Iterable[NativeLinkItem | dict[str, object]] | None, native_library_dirs: Iterable[str | Path] | None, native_include_dirs: Iterable[str | Path] | None, + native_linker_language: str | None, + allow_empty_native: bool = False, ) -> _NativeBuildInputs: """Validate and normalize all caller-native inputs for a build request. @@ -2288,9 +2415,10 @@ def _native_build_inputs( ) ) explicit_include_dirs = _existing_paths(native_include_dirs, kind="Native include", require_directory=True) + linker_language = _native_link_item_language(native_linker_language) # A wrapper has no native implementation without at least one link input. - if ( + if not allow_empty_native and ( not source_paths and not artifact_paths and not libraries @@ -2313,6 +2441,7 @@ def _native_build_inputs( link_item_paths=link_item_paths, library_dirs=library_dirs, explicit_include_dirs=explicit_include_dirs, + linker_language=linker_language, ) @@ -2336,8 +2465,12 @@ def _native_include_dirs(inputs: _NativeBuildInputs, *, output_path: Path) -> tu def _native_inputs_require_fortran(inputs: _NativeBuildInputs) -> bool: """Return whether explicit native language records require a Fortran driver.""" - return "fortran" in inputs.source_languages or any( - item.language == "fortran" for item in (*inputs.explicit_link_items, *(inputs.complete_link_items or ())) + return ( + inputs.linker_language == "fortran" + or "fortran" in inputs.source_languages + or any( + item.language == "fortran" for item in (*inputs.explicit_link_items, *(inputs.complete_link_items or ())) + ) ) @@ -2408,6 +2541,7 @@ def _prepare_native_build_plan( inputs: _NativeBuildInputs, *, output_path: Path, + compiler: Compiler, ) -> tuple[tuple[ObjectFile, ...], NativeBuildPlan]: """Create and validate compiler objects and link inputs for one build.""" include_dirs = _native_include_dirs(inputs, output_path=output_path) @@ -2427,6 +2561,8 @@ def _prepare_native_build_plan( explicit_include_dirs=inputs.explicit_include_dirs, include_dirs=include_dirs, module_dir=output_path if any(source.language == "fortran" for source in source_objects) else None, + compiler=compiler, + linker_language=inputs.linker_language, ) _validate_native_link_paths(plan) return source_objects, plan @@ -2499,6 +2635,7 @@ def _manifest_native_plan(plan: NativeBuildPlan, *, base: Path) -> dict[str, obj "module_dir": _manifest_path(unit.module_dir, base=base) if unit.module_dir is not None else None, "include_dirs": [_manifest_path(path, base=base) for path in unit.include_dirs], "flags": list(unit.flags), + "abi_flags": list(unit.abi_flags), } for unit in plan.compilation_units ], @@ -2517,6 +2654,7 @@ def _manifest_native_plan(plan: NativeBuildPlan, *, base: Path) -> dict[str, obj "include_dirs": [_manifest_path(path, base=base) for path in plan.include_dirs], "library_dirs": [_manifest_path(path, base=base) for path in plan.library_dirs], "link_items": [_manifest_link_item(item, base=base) for item in plan.link_items], + "linker_language": plan.linker_language, } @@ -2543,6 +2681,16 @@ def _manifest_generated_wrapper(result: WrapperBuildResult, *, base: Path) -> di """Serialize physical sources and independently planned native membership.""" return { "sources": [_manifest_path(path, base=base) for path in result.generated_sources], + "compilation_units": [ + { + "source": _manifest_path(unit.source, base=base), + "language": unit.language, + "include_dirs": [_manifest_path(path, base=base) for path in unit.include_dirs], + "flags": list(unit.flags), + "abi_flags": list(unit.abi_flags), + } + for unit in result.generated_compilation_units + ], "native_code_groups": [ { "kind": group.kind.value, @@ -3169,12 +3317,15 @@ def _fortran_wrapper_module( fortran_type_probe_cache_dir: str | Path | None, refresh_fortran_type_probe: bool, assume_intent_in_scalars: bool = False, -) -> tuple[object, SemanticModule, tuple[SemanticModule, ...]]: +) -> tuple[object, SemanticModule, tuple[SemanticModule, ...], tuple[Path, ...]]: """Parse Fortran sources, resolve type facts, and form one wrapper module.""" # Preprocess and parse the complete source project. - preprocessed_sources = { - str(source_path): _fortran_source_for_pipeline(source_path, preprocessing) for source_path in source_paths - } + preprocessed_sources = {} + semantic_dependencies = [] + for source_path in source_paths: + source, dependencies = _fortran_source_and_dependencies(source_path, preprocessing) + preprocessed_sources[str(source_path)] = source + semantic_dependencies.extend(dependencies) parsed = parse_fortran_project(preprocessed_sources) # Measure compiler-dependent values before building semantic IR. @@ -3205,7 +3356,12 @@ def _fortran_wrapper_module( ) _apply_source_python_exports(modules) module_name = _validated_wrapper_module_name(output_name, source_paths[0].stem) - return parsed, _merge_wrapper_modules(modules, name=module_name), tuple(modules) + return ( + parsed, + _merge_wrapper_modules(modules, name=module_name), + tuple(modules), + _unique_paths(semantic_dependencies), + ) def _complete_pyi_fortran_boolean_types( @@ -3314,6 +3470,7 @@ def build_fortran_extension( native_link_items: Iterable[NativeLinkItem | dict[str, object]] | None = None, native_library_dirs: Iterable[str | Path] | None = None, native_include_dirs: Iterable[str | Path] | None = None, + native_linker_language: str | None = None, makefile: bool = False, generate_sources: bool = False, jobs: int | None = None, @@ -3324,6 +3481,7 @@ def build_fortran_extension( standard_logicals: bool = True, _on_total_build_time: Callable[[float], None] | None = None, _plan_only: bool = False, + _external_native_implementation: bool = False, ) -> WrapperBuildResult: """Build a Python extension from one or more Fortran source files. @@ -3376,10 +3534,11 @@ def build_fortran_extension( Additional explicit Fortran or C implementation sources and their language-specific compiler flags. native_objects, native_libraries, native_link_items, - native_library_dirs, native_include_dirs + native_library_dirs, native_include_dirs, native_linker_language Existing artifacts, ``-l`` names, ordered linker records, and search - paths for the native implementation. Use ``native_link_items`` when - linker order is significant. + paths for the native implementation. Use ``native_link_items`` when + linker order is significant, and state ``"fortran"`` or ``"c"`` when + opaque prebuilt inputs require a particular final linker language. makefile, generate_sources Choose a non-executing output mode. ``makefile=True`` writes a replayable ``Makefile.prik``; ``generate_sources=True`` writes wrapper @@ -3419,6 +3578,8 @@ def build_fortran_extension( jobs=jobs, verbose=verbose, ) + if _external_native_implementation and not (generate_sources or _plan_only): + raise ValueError("An external native implementation is valid only for source generation or planning") build_started = time.perf_counter() @@ -3442,11 +3603,13 @@ def build_fortran_extension( complete_native_link_items=None, native_library_dirs=native_library_dirs, native_include_dirs=native_include_dirs, + native_linker_language=native_linker_language, + allow_empty_native=_external_native_implementation, ) type_probe_preprocessing = _type_probe_preprocessing(preprocessing, native_inputs.fortran_source_flags) # 2. Parse source, resolve target facts, and assemble semantic IR. - parsed, module, source_modules = _fortran_wrapper_module( + parsed, module, source_modules, semantic_dependencies = _fortran_wrapper_module( source_paths, preprocessing=preprocessing, type_probe_preprocessing=type_probe_preprocessing, @@ -3479,7 +3642,11 @@ def build_fortran_extension( standard_logicals=standard_logicals, input_compiler=preprocessing.compiler if preprocessing.uses_compiler else None, ) - native_source_objects, native_build_plan = _prepare_native_build_plan(native_inputs, output_path=output_path) + native_source_objects, native_build_plan = _prepare_native_build_plan( + native_inputs, + output_path=output_path, + compiler=compiler, + ) native_compile_batches = _project_compile_batches(parsed, native_source_objects) # 5. Build the extension, or retain the generated source/Makefile plan. @@ -3488,6 +3655,7 @@ def build_fortran_extension( output_dir=output_path, shared_library_output_dir=shared_library_output_path, sources=source_paths, + semantic_dependencies=semantic_dependencies, native_build_plan=native_build_plan, native_dependencies=native_source_objects, native_compile_batches=native_compile_batches, @@ -3526,6 +3694,7 @@ def build_c_extension( c_type_report=None, c_type_probe_runner: list[str] | None = None, export_symbols: Iterable[str] | None = None, + compile_input_sources: bool = True, native_c_sources: Iterable[str | Path] | None = None, native_c_flags: Iterable[str] | None = None, native_fortran_sources: Iterable[str | Path] | None = None, @@ -3536,6 +3705,7 @@ def build_c_extension( native_link_items: Iterable[NativeLinkItem | dict[str, object]] | None = None, native_library_dirs: Iterable[str | Path] | None = None, native_include_dirs: Iterable[str | Path] | None = None, + native_linker_language: str | None = None, strict_wrapper_names: bool = False, collision_adapters: Iterable[str] | None = None, collision_adapter_all: bool = False, @@ -3550,6 +3720,7 @@ def build_c_extension( standard_logicals: bool = True, _on_total_build_time: Callable[[float], None] | None = None, _plan_only: bool = False, + _external_native_implementation: bool = False, ) -> WrapperBuildResult: """Build a direct-only C extension from explicit C implementation sources. @@ -3563,7 +3734,8 @@ def build_c_extension( identifier collision may use a separate C forwarder translation unit. ``export_symbols`` restricts semantic conversion to those exact reachable C functions and can explicitly select declarations from included headers. - ``native_c_sources`` adds separately compiled C inputs, while explicit + ``compile_input_sources`` controls whether the parsed C sources are also + compiled. ``native_c_sources`` adds separately compiled C inputs, while explicit Fortran inputs are supported only as ordinary link dependencies. ``standard_logicals`` controls whether those Fortran inputs are compiled with the option that gives a ``logical`` the representation C expects @@ -3582,6 +3754,8 @@ def build_c_extension( jobs=jobs, verbose=verbose, ) + if _external_native_implementation and not (generate_sources or _plan_only): + raise ValueError("An external native implementation is valid only for source generation or planning") build_started = time.perf_counter() selected_exports = None if export_symbols is None else tuple(export_symbols) source_paths = _c_source_paths(sources) @@ -3590,7 +3764,7 @@ def build_c_extension( native_inputs = _native_build_inputs( native_fortran_sources=native_fortran_sources, native_fortran_flags=native_fortran_flags, - native_c_sources=(*source_paths, *supplemental_c_paths), + native_c_sources=(*(source_paths if compile_input_sources else ()), *supplemental_c_paths), native_c_flags=native_c_flags, native_objects=native_objects, native_libraries=native_libraries, @@ -3598,6 +3772,8 @@ def build_c_extension( complete_native_link_items=None, native_library_dirs=native_library_dirs, native_include_dirs=native_include_dirs, + native_linker_language=native_linker_language, + allow_empty_native=_external_native_implementation, ) requires_fortran = _native_inputs_require_fortran(native_inputs) compiler, preprocessing = _c_build_compiler_and_preprocessing( @@ -3610,6 +3786,7 @@ def build_c_extension( standard_logicals=standard_logicals, ) parsed_sources = tuple(_parse_c_wrapper_source(path, preprocessing) for path in source_paths) + semantic_dependencies = _c_wrapper_semantic_dependencies(parsed_sources, source_paths) # Fail forms that are intrinsically outside the primitive lane before the # ABI probe, generated files, or native build commands. A supported source # may still need the probe to resolve target-sized arithmetic facts. @@ -3660,7 +3837,11 @@ def build_c_extension( output_path, verbose=verbose, ) - native_source_objects, native_build_plan = _prepare_native_build_plan(native_inputs, output_path=output_path) + native_source_objects, native_build_plan = _prepare_native_build_plan( + native_inputs, + output_path=output_path, + compiler=compiler, + ) wrapper_fortran_flags = _compiler_flags(wrapper_fortran_flags) wrapper_c_flags = _compiler_flags(wrapper_c_flags) result = _build_generated_wrapper_extension( @@ -3668,6 +3849,7 @@ def build_c_extension( output_dir=output_path, shared_library_output_dir=shared_library_output_path, sources=source_paths, + semantic_dependencies=semantic_dependencies, native_build_plan=native_build_plan, native_dependencies=native_source_objects, native_compile_batches=_serial_compile_batches(native_source_objects), @@ -3711,6 +3893,7 @@ def build_pyi_extension( native_link_items: Iterable[NativeLinkItem | dict[str, object]] | None = None, native_library_dirs: Iterable[str | Path] | None = None, native_include_dirs: Iterable[str | Path] | None = None, + native_linker_language: str | None = None, output_name: str | None = None, output_dir: str | Path | None = None, strict_wrapper_names: bool = False, @@ -3728,6 +3911,7 @@ def build_pyi_extension( standard_logicals: bool = True, _on_total_build_time: Callable[[float], None] | None = None, _plan_only: bool = False, + _external_native_implementation: bool = False, ) -> WrapperBuildResult: """Build a Python extension from an editable semantic ``.pyi`` contract. @@ -3760,10 +3944,11 @@ def build_pyi_extension( Existing implementation source paths to compile and their language-specific compiler flags. native_objects, native_libraries, native_link_items, - native_library_dirs, native_include_dirs + native_library_dirs, native_include_dirs, native_linker_language Existing artifacts, ``-l`` names, ordered linker records, and search paths. Use ``native_link_items`` to append ordered inputs, or ``complete_native_link_items`` to supply the full ordered link plan. + State a linker language when opaque prebuilt inputs require one. output_name, output_dir Optional Python extension name and build directory. The default name comes from the contract file or package entry. @@ -3804,6 +3989,8 @@ def build_pyi_extension( jobs=jobs, verbose=verbose, ) + if _external_native_implementation and not (generate_sources or _plan_only): + raise ValueError("An external native implementation is valid only for source generation or planning") build_started = time.perf_counter() @@ -3825,6 +4012,8 @@ def build_pyi_extension( complete_native_link_items=complete_native_link_items, native_library_dirs=native_library_dirs, native_include_dirs=native_include_dirs, + native_linker_language=native_linker_language, + allow_empty_native=_external_native_implementation, ) output_path, shared_library_output_path = _wrapper_output_paths(output_dir) @@ -3863,8 +4052,7 @@ def build_pyi_extension( if not _plan_only: output_path.mkdir(parents=True, exist_ok=True) - # 3. Prepare native compilation and link inputs before selecting the compiler. - native_source_objects, native_build_plan = _prepare_native_build_plan(native_inputs, output_path=output_path) + # 3. Select the compiler profile and complete the native compilation plan. compiler = _new_compiler( execute_commands=not generation_only, debug=wrapper_compiler_debug, @@ -3873,6 +4061,11 @@ def build_pyi_extension( input_c_compiler=selected_input_c_compiler, requires_fortran=_native_inputs_require_fortran(native_inputs) or native_language == "fortran", ) + native_source_objects, native_build_plan = _prepare_native_build_plan( + native_inputs, + output_path=output_path, + compiler=compiler, + ) resolved_input_c_compiler = compiler.resolved_executable("c") native_array_build_requirements = native_array_handle_build_requirements(module) @@ -3882,6 +4075,7 @@ def build_pyi_extension( output_dir=output_path, shared_library_output_dir=shared_library_output_path, sources=bundle.paths, + semantic_dependencies=bundle.paths, native_build_plan=native_build_plan, native_dependencies=native_source_objects, native_compile_batches=_serial_compile_batches(native_source_objects), @@ -4010,6 +4204,7 @@ def build_pyi_extension_from_manifest( collision_adapters = _manifest_string_list(extension_section, "collision_adapters") collision_adapter_all = _manifest_bool(extension_section, "collision_adapter_all") positional_only = _manifest_bool(extension_section, "positional_only") + native_linker_language = _native_link_item_language(native_section.get("linker_language")) # 2. Restore native include paths and compiler selection from the manifest. manifest_module_dirs = _manifest_path_list(native_section, "module_dirs", base=base) @@ -4052,6 +4247,7 @@ def build_pyi_extension_from_manifest( native_c_flags=_manifest_string_list(compiler_section, "c_flags"), native_include_dirs=native_include_dirs, native_library_dirs=_manifest_path_list(native_section, "library_dirs", base=base), + native_linker_language=native_linker_language, output_name=requested_name, output_dir=output_path, strict_wrapper_names=strict_wrapper_names, diff --git a/tests/fortran/infrastructure/building/compiling/test_logical_interop_flags.py b/tests/fortran/infrastructure/building/compiling/test_logical_interop_flags.py index e7c14e310..49df78047 100644 --- a/tests/fortran/infrastructure/building/compiling/test_logical_interop_flags.py +++ b/tests/fortran/infrastructure/building/compiling/test_logical_interop_flags.py @@ -43,3 +43,25 @@ def test_compilers_that_already_interoperate_add_no_logical_option(vendor: str, command = _fortran_compile_command(vendor, standard_logicals=True, tmp_path=tmp_path) assert "-standard-semantics" not in command assert "-Munixlogical" not in command + + +@pytest.mark.parametrize( + ("vendor", "expected"), + [ + ("intel", ("-standard-semantics",)), + ("PGI", ("-Munixlogical",)), + ("nvidia", ("-Munixlogical",)), + ("GNU", ()), + ], +) +def test_compiler_exposes_required_logical_abi_flags_for_external_builds(vendor: str, expected: tuple[str, ...]): + compiler = Compiler(vendor, execute_commands=False) + + assert compiler.required_abi_flags("fortran") == expected + assert compiler.required_abi_flags("c") == () + + +def test_external_build_can_disable_required_logical_abi_flags(): + compiler = Compiler("intel", execute_commands=False, standard_logicals=False) + + assert compiler.required_abi_flags("fortran") == () diff --git a/tests/fortran/infrastructure/building/end_to_end/test_cmake_builds.py b/tests/fortran/infrastructure/building/end_to_end/test_cmake_builds.py index 509684c21..670a60b94 100644 --- a/tests/fortran/infrastructure/building/end_to_end/test_cmake_builds.py +++ b/tests/fortran/infrastructure/building/end_to_end/test_cmake_builds.py @@ -3,6 +3,7 @@ from __future__ import annotations import importlib +import importlib.metadata import json import os from pathlib import Path @@ -10,8 +11,10 @@ import subprocess import sys import tempfile +import venv import numpy as np +from packaging.version import Version import pytest @@ -31,6 +34,7 @@ BRIDGE_NATIVE = ( REPOSITORY_ROOT / "tests" / "fortran" / "functions" / "end_to_end" / "fixtures" / "native" / "free_external.f90" ) +SETUPTOOLS_SUPPORTS_PROJECT_DATA_FILES = Version(importlib.metadata.version("setuptools")) >= Version("77.0.3") def _environment() -> dict[str, str]: @@ -40,8 +44,13 @@ def _environment() -> dict[str, str]: return environment -def _run(command: list[str], *, cwd: Path | None = None) -> subprocess.CompletedProcess[str]: - result = subprocess.run(command, cwd=cwd, env=_environment(), capture_output=True, text=True) +def _run( + command: list[str], + *, + cwd: Path | None = None, + environment: dict[str, str] | None = None, +) -> subprocess.CompletedProcess[str]: + result = subprocess.run(command, cwd=cwd, env=environment or _environment(), capture_output=True, text=True) if result.returncode: raise AssertionError( f"Command failed ({result.returncode}): {' '.join(command)}\n" @@ -57,6 +66,8 @@ def _configure_and_build( language: str, use_ninja: bool = True, build_project: bool = True, + environment: dict[str, str] | None = None, + python_executable: Path | None = None, ) -> None: command = ["cmake", "-S", str(project), "-B", str(build)] if use_ninja and shutil.which("ninja"): @@ -64,9 +75,11 @@ def _configure_and_build( command.append(f"-DCMAKE_C_COMPILER={shutil.which('gcc')}") if language == "fortran": command.append(f"-DCMAKE_Fortran_COMPILER={shutil.which('gfortran')}") - _run(command) + if python_executable is not None: + command.append(f"-DPython_EXECUTABLE={python_executable}") + _run(command, environment=environment) if build_project: - _run(["cmake", "--build", str(build), "-j2"]) + _run(["cmake", "--build", str(build), "-j2"], environment=environment) def _import_extension(module_name: str, build: Path): @@ -152,6 +165,102 @@ def test_use_prik_cmake_builds_source_first_fortran_module(tmp_path: Path): assert module.square(np.float64(3.0)) == np.float64(9.0) +@pytest.mark.fortran_end_to_end +@pytest.mark.skipif( + shutil.which("cmake") is None or shutil.which("gfortran") is None or shutil.which("gcc") is None, + reason="CMake, gfortran, and gcc are required", +) +def test_use_prik_cmake_preserves_per_source_compile_flags(tmp_path: Path): + project = tmp_path / "compile flag scopes" + project.mkdir() + (project / "native.f90").write_text( + "real(8) function native_value(x) result(y)\n real(8), intent(in) :: x\n y = x\nend function native_value\n", + encoding="utf-8", + ) + (project / "support.c").write_text("int prik_native_support(void) { return 0; }\n", encoding="utf-8") + _write_project( + project, + """set(CMAKE_EXPORT_COMPILE_COMMANDS ON) +prik_add_module( + compile_flag_scopes + SOURCES native.f90 + C_SOURCES support.c + FORTRAN_FLAGS -DPRIK_NATIVE_FORTRAN + C_FLAGS -DPRIK_NATIVE_C + WRAPPER_FORTRAN_FLAGS -DPRIK_WRAPPER_FORTRAN + WRAPPER_C_FLAGS -DPRIK_WRAPPER_C +) +""", + ) + build = project / "build" + _configure_and_build(project, build, language="fortran") + commands = json.loads((build / "compile_commands.json").read_text(encoding="utf-8")) + by_name = {Path(record["file"]).name: record["command"] for record in commands} + + assert "-DPRIK_NATIVE_FORTRAN" in by_name["native.f90"] + assert "-DPRIK_WRAPPER_FORTRAN" not in by_name["native.f90"] + assert "-DPRIK_NATIVE_C" in by_name["support.c"] + assert "-DPRIK_WRAPPER_C" not in by_name["support.c"] + bridge_command = next(command for name, command in by_name.items() if name.endswith("_wrapper.f90")) + binding_command = next(command for name, command in by_name.items() if name.endswith("_wrapper.c")) + assert "-DPRIK_WRAPPER_FORTRAN" in bridge_command + assert "-DPRIK_NATIVE_FORTRAN" not in bridge_command + assert "-DPRIK_WRAPPER_C" in binding_command + assert "-DPRIK_NATIVE_C" not in binding_command + + +@pytest.mark.fortran_end_to_end +@pytest.mark.skipif( + shutil.which("cmake") is None or shutil.which("gfortran") is None or shutil.which("gcc") is None, + reason="CMake, gfortran, and gcc are required", +) +def test_use_prik_cmake_maps_required_logical_abi_flags(tmp_path: Path): + project = tmp_path / "logical abi flags" + toolchain = project / "toolchain" + toolchain.mkdir(parents=True) + for name, compiler in (("ifort", shutil.which("gfortran")), ("icx", shutil.which("gcc"))): + executable = toolchain / name + executable.write_text(f'#!/bin/sh\nexec "{compiler}" "$@"\n', encoding="utf-8") + executable.chmod(0o755) + source_text = ( + "logical function logical_identity(value) result(output)\n" + " logical, intent(in) :: value\n" + " output = value\n" + "end function logical_identity\n" + ) + (project / "logical_default.f90").write_text(source_text, encoding="utf-8") + (project / "logical_disabled.f90").write_text(source_text, encoding="utf-8") + _write_project( + project, + """set(CMAKE_EXPORT_COMPILE_COMMANDS ON) +prik_add_module(abi_default SOURCES logical_default.f90) +prik_add_module(abi_disabled SOURCES logical_disabled.f90 NO_STANDARD_LOGICALS) +""", + ) + build = project / "build" + _run( + [ + "cmake", + "-S", + str(project), + "-B", + str(build), + "-G", + "Ninja" if shutil.which("ninja") else "Unix Makefiles", + f"-DCMAKE_C_COMPILER={shutil.which('gcc')}", + f"-DCMAKE_Fortran_COMPILER={toolchain / 'ifort'}", + ] + ) + commands = json.loads((build / "compile_commands.json").read_text(encoding="utf-8")) + default_commands = [record["command"] for record in commands if "abi_default" in record["command"]] + disabled_commands = [record["command"] for record in commands if "abi_disabled" in record["command"]] + + assert default_commands + assert any("-standard-semantics" in command for command in default_commands) + assert disabled_commands + assert all("-standard-semantics" not in command for command in disabled_commands) + + @pytest.mark.fortran_end_to_end @pytest.mark.skipif( shutil.which("cmake") is None or shutil.which("gfortran") is None or shutil.which("gcc") is None, @@ -261,6 +370,63 @@ def test_generate_cmake_preserves_ordered_native_link_items(tmp_path: Path): assert positions == tuple(sorted(positions)) +@pytest.mark.fortran_end_to_end +def test_generate_cmake_keeps_compile_option_ownership_explicit(tmp_path: Path): + source = tmp_path / "interface.f90" + source.write_text("subroutine interface()\nend subroutine interface\n", encoding="utf-8") + archive = tmp_path / "libimplementation.a" + archive.touch() + project = tmp_path / "explicit options" + _run( + [ + sys.executable, + "-m", + "prik", + "generate", + "--cmake", + str(source), + "--no-compile-input-sources", + "--native-objects", + str(archive), + "--native-linker-language", + "fortran", + "--native-compile-flags=-DPRIK_NATIVE", + "--wrapper-fortran-flags=-DPRIK_WRAPPER_FORTRAN", + "--wrapper-c-flags=-DPRIK_WRAPPER_C", + "--no-standard-logicals", + "--lto", + "--out-dir", + str(project), + ] + ) + cmake_lists = (project / "CMakeLists.txt").read_text(encoding="utf-8") + + assert 'FORTRAN_FLAGS\n "-DPRIK_NATIVE"' in cmake_lists + assert 'WRAPPER_FORTRAN_FLAGS\n "-DPRIK_WRAPPER_FORTRAN"' in cmake_lists + assert 'WRAPPER_C_FLAGS\n "-DPRIK_WRAPPER_C"' in cmake_lists + assert "LINKER_LANGUAGE Fortran" in cmake_lists + assert "NO_STANDARD_LOGICALS" in cmake_lists + assert "INTERPROCEDURAL_OPTIMIZATION TRUE" in cmake_lists + assert "--compiler" not in cmake_lists + + +@pytest.mark.parametrize("option", ["--compiler=gfortran", "--wrapper-compiler-debug"]) +@pytest.mark.fortran_end_to_end +def test_generate_cmake_rejects_ambiguous_direct_compiler_options(tmp_path: Path, option: str): + source = tmp_path / "source.f90" + source.write_text("subroutine source()\nend subroutine source\n", encoding="utf-8") + + result = subprocess.run( + [sys.executable, "-m", "prik", "generate", "--cmake", str(source), option], + env=_environment(), + capture_output=True, + text=True, + ) + + assert result.returncode == 2 + assert "generate --cmake" in result.stderr + + @pytest.mark.fortran_end_to_end @pytest.mark.skipif( shutil.which("cmake") is None or shutil.which("gfortran") is None or shutil.which("gcc") is None, @@ -363,6 +529,72 @@ def test_use_prik_cmake_contract_dependency_regenerates_wrapper(tmp_path: Path): assert "Generate PRIK wrapper sources for contract_example" in contract_output +@pytest.mark.fortran_end_to_end +@pytest.mark.skipif(shutil.which("cmake") is None or shutil.which("gcc") is None, reason="CMake and gcc are required") +def test_use_prik_cmake_regenerates_after_nested_c_header_changes(tmp_path: Path): + project = tmp_path / "nested c dependency" + include_dir = project / "include" + include_dir.mkdir(parents=True) + inner_header = include_dir / "inner.h" + inner_header.write_text("double c_square(double value);\n", encoding="utf-8") + (include_dir / "api.h").write_text('#include "inner.h"\n', encoding="utf-8") + (project / "module.c").write_text( + '#include "api.h"\ndouble c_square(double value) { return value * value; }\n', + encoding="utf-8", + ) + _write_project( + project, + f"""prik_add_module( + c_header_dependency + C_SOURCES module.c + INCLUDE_DIRS "{include_dir.as_posix()}" +) +""", + languages="C", + ) + build = project / "build" + _configure_and_build(project, build, language="c") + + inner_header.write_text("double c_square(double input);\n", encoding="utf-8") + rebuilt = _run(["cmake", "--build", str(build), "-j2"]) + + assert "Generate PRIK wrapper sources for c_header_dependency" in rebuilt.stdout + rebuilt.stderr + + +@pytest.mark.fortran_end_to_end +@pytest.mark.skipif( + shutil.which("cmake") is None or shutil.which("gfortran") is None or shutil.which("gcc") is None, + reason="CMake, gfortran, and gcc are required", +) +def test_use_prik_cmake_regenerates_after_fortran_include_changes(tmp_path: Path): + project = tmp_path / "fortran include dependency" + project.mkdir() + include = project / "declarations.inc" + include.write_text("implicit none\n real(8), intent(in) :: x\n", encoding="utf-8") + (project / "included.f90").write_text( + "real(8) function included_square(x) result(y)\n" + " include 'declarations.inc'\n" + " y = x * x\n" + "end function included_square\n", + encoding="utf-8", + ) + _write_project( + project, + """prik_add_module( + fortran_include_dependency + FORTRAN_SOURCES included.f90 +) +""", + ) + build = project / "build" + _configure_and_build(project, build, language="fortran") + + include.write_text("implicit none\n double precision, intent(in) :: x\n", encoding="utf-8") + rebuilt = _run(["cmake", "--build", str(build), "-j2"]) + + assert "Generate PRIK wrapper sources for fortran_include_dependency" in rebuilt.stdout + rebuilt.stderr + + @pytest.mark.fortran_end_to_end @pytest.mark.skipif( shutil.which("cmake") is None or shutil.which("gfortran") is None or shutil.which("gcc") is None, @@ -564,6 +796,87 @@ def test_use_prik_cmake_links_a_normal_fortran_library_target(tmp_path: Path): assert module.call_native(np.float64(2.0), np.float64(3.0)) == np.float64(5.0) +@pytest.mark.fortran_end_to_end +@pytest.mark.skipif( + shutil.which("cmake") is None or shutil.which("gfortran") is None or shutil.which("gcc") is None, + reason="CMake, gfortran, and gcc are required", +) +def test_use_prik_cmake_uses_target_as_the_only_native_implementation(tmp_path: Path): + project = tmp_path / "target only implementation" + project.mkdir() + interface = ( + "real(8) function target_square(value) result(result)\n" + " real(8), intent(in) :: value\n" + " result = value * value\n" + "end function target_square\n" + ) + (project / "interface.f90").write_text(interface, encoding="utf-8") + (project / "implementation.f90").write_text(interface, encoding="utf-8") + _write_project( + project, + """add_library(native_math STATIC implementation.f90) +prik_add_module( + target_only + SOURCES interface.f90 + NO_COMPILE_INPUT_SOURCES + LINK_LIBRARIES native_math +) +""", + ) + build = project / "build" + _configure_and_build(project, build, language="fortran") + + module = _import_extension("target_only", build) + assert module.target_square(np.float64(4.0)) == np.float64(16.0) + + +@pytest.mark.fortran_end_to_end +@pytest.mark.skipif( + shutil.which("cmake") is None + or shutil.which("gfortran") is None + or shutil.which("gcc") is None + or shutil.which("ar") is None, + reason="CMake, gfortran, gcc, and ar are required", +) +def test_use_prik_cmake_selects_fortran_linker_for_raw_archive(tmp_path: Path): + project = tmp_path / "raw fortran archive" + project.mkdir() + source_text = ( + "integer(c_int) function raw_add_two(value) bind(C, name='raw_add_two_symbol') result(output)\n" + " use iso_c_binding, only: c_int\n" + " integer(c_int), value, intent(in) :: value\n" + " character(len=16) :: buffer\n" + " write(buffer, '(I0)') value\n" + " read(buffer, *) output\n" + " output = output + 2_c_int\n" + "end function raw_add_two\n" + ) + interface = project / "interface.f90" + implementation = project / "implementation.f90" + interface.write_text(source_text, encoding="utf-8") + implementation.write_text(source_text, encoding="utf-8") + native_object = project / "implementation.o" + archive = project / "libraw_math.a" + _run([shutil.which("gfortran"), "-fPIC", "-c", str(implementation), "-o", str(native_object)]) + _run([shutil.which("ar"), "rcs", str(archive), str(native_object)]) + _write_project( + project, + f"""prik_add_module( + raw_archive + SOURCES interface.f90 + NO_COMPILE_INPUT_SOURCES + LINKER_LANGUAGE Fortran + LINK_LIBRARIES "{archive.as_posix()}" +) +""", + ) + build = project / "build" + _configure_and_build(project, build, language="fortran") + + module = _import_extension("raw_archive", build) + assert module.raw_add_two(np.int32(5)) == np.int32(7) + + @pytest.mark.fortran_end_to_end @pytest.mark.skipif( shutil.which("cmake") is None or shutil.which("gfortran") is None or shutil.which("gcc") is None, @@ -645,3 +958,95 @@ def test_use_prik_cmake_links_a_cmake_discovered_blas_target(tmp_path: Path): _configure_and_build(project, build, language="fortran") module = _import_extension("blas_example", build) assert module.blas_dot(np.array([1.0, 2.0]), np.array([3.0, 4.0])) == np.float64(11.0) + + +@pytest.mark.fortran_end_to_end +@pytest.mark.slow +@pytest.mark.skipif( + shutil.which("cmake") is None + or shutil.which("gfortran") is None + or shutil.which("gcc") is None + or not SETUPTOOLS_SUPPORTS_PROJECT_DATA_FILES, + reason="CMake, compilers, and setuptools 77 or newer are required", +) +def test_installed_wheel_discovers_and_builds_with_use_prik(tmp_path: Path): + distribution_dir = tmp_path / "dist" + _run( + [ + sys.executable, + "-m", + "build", + "--wheel", + "--no-isolation", + "--outdir", + str(distribution_dir), + ], + cwd=REPOSITORY_ROOT, + ) + wheel = next(distribution_dir.glob("prik-*.whl")) + environment_dir = tmp_path / "installed" + venv.EnvBuilder(with_pip=True, system_site_packages=True).create(environment_dir) + installed_python = environment_dir / "bin" / "python" + clean_environment = os.environ.copy() + clean_environment.pop("PYTHONPATH", None) + _run( + [str(installed_python), "-m", "pip", "install", "--no-deps", str(wheel)], + environment=clean_environment, + ) + discovery = _run( + [ + str(installed_python), + "-I", + "-c", + "from prik.cmake import cmake_module_dir; print(cmake_module_dir() / 'UsePRIK.cmake')", + ], + environment=clean_environment, + ) + helper = Path(discovery.stdout.strip()) + assert helper.is_file() + assert REPOSITORY_ROOT not in helper.parents + + source = tmp_path / "installed_square.f90" + source.write_text( + "real(8) function installed_square(value) result(output)\n" + " real(8), intent(in) :: value\n" + " output = value * value\n" + "end function installed_square\n", + encoding="utf-8", + ) + project = tmp_path / "installed project" + _run( + [ + str(installed_python), + "-I", + "-m", + "prik", + "generate", + "--cmake", + str(source), + "--out-dir", + str(project), + ], + environment=clean_environment, + ) + build = project / "build" + _configure_and_build( + project, + build, + language="fortran", + environment=clean_environment, + python_executable=installed_python, + ) + artifact = next(build.rglob("installed_square*.so")) + imported = _run( + [ + str(installed_python), + "-I", + "-c", + f"import sys; sys.path.insert(0, {str(artifact.parent)!r}); " + "import installed_square; assert installed_square.installed_square(3.0) == 9.0", + ], + cwd=artifact.parent, + environment=clean_environment, + ) + assert imported.returncode == 0 diff --git a/tests/fortran/infrastructure/building/pipeline/test_generated_wrapper_build.py b/tests/fortran/infrastructure/building/pipeline/test_generated_wrapper_build.py index b5c4aff4f..c3faec8c9 100644 --- a/tests/fortran/infrastructure/building/pipeline/test_generated_wrapper_build.py +++ b/tests/fortran/infrastructure/building/pipeline/test_generated_wrapper_build.py @@ -39,6 +39,9 @@ def compile_object(self, object_file, *, verbose=False): module_file = object_file.object_path.parent / f"{object_file.source.stem}.mod" module_file.write_text("fortran module\n", encoding="utf-8") + def required_abi_flags(self, language): + return ("-frequired-abi",) if language == "fortran" else () + def link_extension( self, *, @@ -176,6 +179,24 @@ def scale(x: Float64) -> Float64: ... assert result.build_makefile is None assert result.native_build_plan == native_plan assert result.generated_sources == (bridge_source, binding_source, header) + assert [unit.to_dict() for unit in result.generated_compilation_units] == [ + { + "source": str(bridge_source), + "language": "fortran", + "include_dirs": [str(native_dir)], + "flags": ["-O2"], + "abi_flags": ["-frequired-abi"], + }, + { + "source": str(binding_source), + "language": "c", + "include_dirs": [str(native_dir)], + "flags": ["-O3"], + "abi_flags": [], + }, + ] + assert result.linker_language == "fortran" + assert result.extension_link_flags == ("-O3",) assert bridge_obj.object_path in result.generated_files assert binding_obj.object_path in result.generated_files assert native_support_header in result.generated_files diff --git a/tests/fortran/infrastructure/building/pipeline/test_pyi_build_modes.py b/tests/fortran/infrastructure/building/pipeline/test_pyi_build_modes.py index 778218f39..097b953d7 100644 --- a/tests/fortran/infrastructure/building/pipeline/test_pyi_build_modes.py +++ b/tests/fortran/infrastructure/building/pipeline/test_pyi_build_modes.py @@ -216,7 +216,7 @@ def test_pyi_makefile_manifest_and_replay_workflows(tmp_path: Path): assert manifest_path == build_dir / "prik-build.json" assert makefile_path == build_dir / "Makefile.prik" assert manifest == payload["manifest"] - assert manifest["schema_version"] == 4 + assert manifest["schema_version"] == 5 assert manifest["build_kind"] == "pyi-wrapper" assert manifest["compiler"]["input_executable"] == str(selected_compiler) assert Path(manifest["compiler"]["input_c_executable"]).resolve() == Path(shutil.which("gcc")).resolve() diff --git a/tests/fortran/infrastructure/cli/pipeline/test_argument_contract.py b/tests/fortran/infrastructure/cli/pipeline/test_argument_contract.py index 2aa899086..405ebc92b 100644 --- a/tests/fortran/infrastructure/cli/pipeline/test_argument_contract.py +++ b/tests/fortran/infrastructure/cli/pipeline/test_argument_contract.py @@ -408,7 +408,7 @@ def test_manifest_compiler_override_targets_only_its_recorded_native_language( manifest.write_text( json.dumps( { - "schema_version": 4, + "schema_version": 5, "build_kind": "pyi-wrapper", "extension": {"native_language": native_language}, } From d8db408ff18467adc7c047572681aebec804ba41 Mon Sep 17 00:00:00 2001 From: said Date: Thu, 10 Sep 2026 18:56:51 +0100 Subject: [PATCH 03/15] codex: fix remaining CMake integration issues --- CHANGELOG.md | 4 + cmake/UsePRIK.cmake | 107 +++++-- docs/developer/packages/pipeline.md | 7 +- docs/user/guide/cmake.md | 28 +- prik/cmake.py | 1 + .../building/end_to_end/test_cmake_builds.py | 264 ++++++++++++++++-- 6 files changed, 373 insertions(+), 38 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 16eff765a..217a46d2b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,10 @@ release tags add a leading `v` to the package version. compiler-required ABI options, preprocessing dependencies, and linker language from PRIK's completed build plan. +- CMake contract modules now distinguish semantic `NATIVE_LANGUAGE` from the + final `LINKER_LANGUAGE`, keep native compilation flags target-local, and + report a clear error when CMake's C language is not enabled. + - `prik-build.json` schema 5 records generated/native compilation-unit ABI flags and explicit native linker-language requirements. diff --git a/cmake/UsePRIK.cmake b/cmake/UsePRIK.cmake index a2f34805a..80b27301d 100644 --- a/cmake/UsePRIK.cmake +++ b/cmake/UsePRIK.cmake @@ -116,17 +116,26 @@ function(_prik_apply_compilation_unit target json group index generated source_r ) list(APPEND _prik_rebased_include_dirs "${_prik_rebased_include_dir}") endforeach() - if(_prik_unit_flags) - set_property( - SOURCE "${_prik_unit_source}" - APPEND PROPERTY COMPILE_OPTIONS ${_prik_unit_flags} - ) - endif() if(_prik_unit_language STREQUAL "fortran") set(_prik_cmake_unit_language Fortran) else() set(_prik_cmake_unit_language C) endif() + if(_prik_unit_flags) + if(generated) + set_property( + SOURCE "${_prik_unit_source}" + APPEND PROPERTY COMPILE_OPTIONS ${_prik_unit_flags} + ) + else() + foreach(_prik_unit_flag IN LISTS _prik_unit_flags) + target_compile_options( + "${target}" PRIVATE + "$<$:${_prik_unit_flag}>" + ) + endforeach() + endif() + endif() foreach(_prik_abi_flag IN LISTS _prik_unit_abi_flags) set(_prik_abi_key "${_prik_cmake_unit_language}:${_prik_abi_flag}") get_property(_prik_applied_abi_flags TARGET "${target}" PROPERTY _PRIK_APPLIED_ABI_FLAGS) @@ -138,10 +147,14 @@ function(_prik_apply_compilation_unit target json group index generated source_r endif() endforeach() if(_prik_rebased_include_dirs) - set_property( - SOURCE "${_prik_unit_source}" - APPEND PROPERTY INCLUDE_DIRECTORIES ${_prik_rebased_include_dirs} - ) + if(generated) + set_property( + SOURCE "${_prik_unit_source}" + APPEND PROPERTY INCLUDE_DIRECTORIES ${_prik_rebased_include_dirs} + ) + else() + target_include_directories("${target}" PRIVATE ${_prik_rebased_include_dirs}) + endif() endif() endfunction() @@ -162,7 +175,7 @@ function(prik_add_module name) endif() set(_options NO_COMPILE_INPUT_SOURCES NO_STANDARD_LOGICALS) - set(_one_value_arguments CONTRACT LINKER_LANGUAGE) + set(_one_value_arguments CONTRACT NATIVE_LANGUAGE LINKER_LANGUAGE) set(_multi_value_arguments SOURCES FORTRAN_SOURCES @@ -183,6 +196,14 @@ function(prik_add_module name) endif() _prik_validate_args(PRIK_PRIK_ARGS) + if(NOT CMAKE_C_COMPILER OR NOT CMAKE_C_COMPILER_ID) + message( + FATAL_ERROR + "PRIK Python extensions require CMake's C language to be enabled. " + "Use project(... LANGUAGES C Fortran) or enable_language(C)." + ) + endif() + if(PRIK_CONTRACT AND PRIK_SOURCES) message(FATAL_ERROR "prik_add_module(${name}) cannot combine CONTRACT and SOURCES") endif() @@ -209,19 +230,45 @@ function(prik_add_module name) message(FATAL_ERROR "prik_add_module(${name}) LINKER_LANGUAGE must be C or Fortran") endif() endif() + if(PRIK_NATIVE_LANGUAGE) + string(TOLOWER "${PRIK_NATIVE_LANGUAGE}" _prik_native_language) + if(NOT _prik_native_language STREQUAL "c" AND NOT _prik_native_language STREQUAL "fortran") + message(FATAL_ERROR "prik_add_module(${name}) NATIVE_LANGUAGE must be C or Fortran") + endif() + if(NOT _prik_contract) + message(FATAL_ERROR "prik_add_module(${name}) NATIVE_LANGUAGE is only valid with CONTRACT") + endif() + endif() if(_prik_contract) set(_prik_wrapper_sources) set(_prik_native_fortran_sources ${_prik_fortran_sources}) set(_prik_native_c_sources ${_prik_c_sources}) - if(_prik_native_fortran_sources) + if(_prik_native_language) + set(_prik_language "${_prik_native_language}") + elseif(_prik_native_fortran_sources AND _prik_native_c_sources) + message( + FATAL_ERROR + "prik_add_module(${name}) CONTRACT with mixed Fortran and C implementation " + "sources requires NATIVE_LANGUAGE" + ) + elseif(_prik_native_fortran_sources) set(_prik_language fortran) elseif(_prik_native_c_sources) set(_prik_language c) - elseif(PRIK_LINK_LIBRARIES AND _prik_linker_language) - set(_prik_language "${_prik_linker_language}") else() - message(FATAL_ERROR "PRIK contract module ${name} requires native sources, or LINK_LIBRARIES with LINKER_LANGUAGE") + message( + FATAL_ERROR + "prik_add_module(${name}) source-free CONTRACT requires NATIVE_LANGUAGE " + "plus LINK_LIBRARIES" + ) + endif() + if(NOT _prik_native_fortran_sources AND NOT _prik_native_c_sources AND NOT PRIK_LINK_LIBRARIES) + message( + FATAL_ERROR + "prik_add_module(${name}) source-free CONTRACT requires LINK_LIBRARIES " + "or native implementation sources" + ) endif() else() if(_prik_sources) @@ -254,6 +301,14 @@ function(prik_add_module name) _prik_validate_source_suffixes(fortran _prik_native_fortran_sources) _prik_validate_source_suffixes(c _prik_native_c_sources) + if(_prik_linker_language STREQUAL "fortran" AND NOT CMAKE_Fortran_COMPILER) + message( + FATAL_ERROR + "PRIK module ${name} requires CMake's Fortran language to be enabled for LINKER_LANGUAGE Fortran. " + "Use project(... LANGUAGES C Fortran) or enable_language(Fortran)." + ) + endif() + set(_prik_output_dir "${CMAKE_CURRENT_BINARY_DIR}/prik/${name}") file(MAKE_DIRECTORY "${_prik_output_dir}") @@ -372,6 +427,21 @@ function(prik_add_module name) endforeach() endif() + set(_prik_native_target) + if(_prik_native_target_sources) + set(_prik_native_target "prik_${name}_native_objects") + if(TARGET "${_prik_native_target}") + message(FATAL_ERROR "PRIK internal target already exists: ${_prik_native_target}") + endif() + add_library("${_prik_native_target}" OBJECT ${_prik_native_target_sources}) + set_target_properties("${_prik_native_target}" PROPERTIES POSITION_INDEPENDENT_CODE ON) + if(_prik_required_linker_language STREQUAL "fortran") + set_target_properties( + "${_prik_native_target}" PROPERTIES Fortran_MODULE_DIRECTORY "${_prik_output_dir}" + ) + endif() + endif() + execute_process( COMMAND "${Python_EXECUTABLE}" -c "import numpy; print(numpy.get_include())" RESULT_VARIABLE _prik_numpy_result @@ -422,8 +492,9 @@ function(prik_add_module name) if(_prik_additional_outputs) target_sources("${name}" PRIVATE ${_prik_additional_outputs}) endif() - if(_prik_native_target_sources) - target_sources("${name}" PRIVATE ${_prik_native_target_sources}) + if(_prik_native_target) + target_sources("${name}" PRIVATE "$") + add_dependencies("${name}" "${_prik_native_target}") endif() if(_prik_generated_unit_count GREATER 0) math(EXPR _prik_last_generated_unit_index "${_prik_generated_unit_count} - 1") @@ -438,7 +509,7 @@ function(prik_add_module name) math(EXPR _prik_last_native_unit_index "${_prik_native_unit_count} - 1") foreach(_prik_native_unit_index RANGE 0 ${_prik_last_native_unit_index}) _prik_apply_compilation_unit( - "${name}" "${_prik_plan_json}" native ${_prik_native_unit_index} FALSE + "${_prik_native_target}" "${_prik_plan_json}" native ${_prik_native_unit_index} FALSE "${_prik_plan_dir}" "${_prik_output_dir}" ) endforeach() diff --git a/docs/developer/packages/pipeline.md b/docs/developer/packages/pipeline.md index 80b8e4b84..25962ae45 100644 --- a/docs/developer/packages/pipeline.md +++ b/docs/developer/packages/pipeline.md @@ -119,8 +119,11 @@ Build integrations consume the completed result rather than compiler command logs. Native and generated compilation units retain their own requested flags, required ABI flags, and include directories; the result also records every semantic/preprocessing dependency and the final linker language. This is the -lossless boundary used by `UsePRIK.cmake` for source properties, regeneration, -and linker-driver selection. +lossless boundary used by `UsePRIK.cmake` for regeneration, linker-driver +selection, and target-local compilation. CMake places native units in a +private per-module object target so source properties cannot leak between +PRIK extension targets; generated sources remain on the Python extension +target. `WrapperBuildResult` and saved `.pyi` manifests report each generated native group's kind, language, member keys, and source paths, so zero-source, diff --git a/docs/user/guide/cmake.md b/docs/user/guide/cmake.md index b8a7c69be..203435b1d 100644 --- a/docs/user/guide/cmake.md +++ b/docs/user/guide/cmake.md @@ -64,8 +64,29 @@ cmake --build build `prik_add_module()` also accepts `SOURCES` for source-first input, `CONTRACT` with `FORTRAN_SOURCES` or `C_SOURCES` for an authored semantic `.pyi`, -`INCLUDE_DIRS`, `MODULE_DIRS`, source-specific compile flags, +`INCLUDE_DIRS`, `MODULE_DIRS`, native and generated-source compile flag groups, `LINK_LIBRARIES`, `LINK_OPTIONS`, and additional generation-only `PRIK_ARGS`. +For a contract backed only by opaque native inputs, use `NATIVE_LANGUAGE` to +state the contract ABI language and `LINKER_LANGUAGE` to state the final CMake +linker driver independently: + +```cmake +prik_add_module( + c_api + CONTRACT api.pyi + NATIVE_LANGUAGE C + LINKER_LANGUAGE Fortran + LINK_LIBRARIES native_fortran_archive +) +``` + +When native source files use one language, PRIK infers `NATIVE_LANGUAGE` from +`FORTRAN_SOURCES` or `C_SOURCES`. Set it explicitly when the contract ABI +differs from the implementation source language or when both source languages +are present. A source-free contract must state it explicitly. CMake's C +language must be enabled because every PRIK extension contains generated C +binding code. + The flag groups remain separate: - `FORTRAN_FLAGS` and `C_FLAGS` apply only to user-owned native sources. @@ -76,6 +97,8 @@ The flag groups remain separate: PRIK adds compiler-profile flags required by its ABI plan to the affected Fortran sources. `NO_STANDARD_LOGICALS` disables PRIK's Intel/NVIDIA logical interoperability option when compatibility with prebuilt objects requires it. +Only mandatory ABI flags are exported from PRIK's plan; recommended compiler +profile options remain the CMake toolchain's responsibility. CMake build type, debug, and interprocedural-optimization settings remain normal CMake target properties; `PRIK_ARGS` rejects compiler and compilation options that would bypass those target settings. @@ -118,7 +141,8 @@ The same form accepts normal project targets such as `native_math` and Normal Fortran sources and targets carry their link-language requirements through CMake. For a raw archive or shared library whose language is otherwise opaque, add `LINKER_LANGUAGE Fortran`; PRIK records that requirement in its -plan and the extension uses CMake's Fortran linker driver. +plan and the extension uses CMake's Fortran linker driver. This is independent +of `NATIVE_LANGUAGE`, which controls semantic-contract interpretation. ## Standalone generated project diff --git a/prik/cmake.py b/prik/cmake.py index 4cb718ae0..655c21d9d 100644 --- a/prik/cmake.py +++ b/prik/cmake.py @@ -238,6 +238,7 @@ def _append_source_declarations( lines.append(" NO_COMPILE_INPUT_SOURCES") if inputs.contract is not None: lines.append(f" CONTRACT {_cmake_string(_relative_path(inputs.contract, base))}") + lines.append(f" NATIVE_LANGUAGE {language.capitalize()}") elif not no_compile_input_sources and not inputs.native_fortran and not inputs.native_c: keyword = "C_SOURCES" if language == "c" else "FORTRAN_SOURCES" _append_block(lines, keyword, inputs.semantic_sources, base=base) diff --git a/tests/fortran/infrastructure/building/end_to_end/test_cmake_builds.py b/tests/fortran/infrastructure/building/end_to_end/test_cmake_builds.py index 670a60b94..6ac3bb142 100644 --- a/tests/fortran/infrastructure/building/end_to_end/test_cmake_builds.py +++ b/tests/fortran/infrastructure/building/end_to_end/test_cmake_builds.py @@ -3,7 +3,6 @@ from __future__ import annotations import importlib -import importlib.metadata import json import os from pathlib import Path @@ -14,7 +13,6 @@ import venv import numpy as np -from packaging.version import Version import pytest @@ -34,7 +32,6 @@ BRIDGE_NATIVE = ( REPOSITORY_ROOT / "tests" / "fortran" / "functions" / "end_to_end" / "fixtures" / "native" / "free_external.f90" ) -SETUPTOOLS_SUPPORTS_PROJECT_DATA_FILES = Version(importlib.metadata.version("setuptools")) >= Version("77.0.3") def _environment() -> dict[str, str]: @@ -209,6 +206,39 @@ def test_use_prik_cmake_preserves_per_source_compile_flags(tmp_path: Path): assert "-DPRIK_NATIVE_C" not in binding_command +@pytest.mark.fortran_end_to_end +@pytest.mark.skipif( + shutil.which("cmake") is None or shutil.which("gfortran") is None or shutil.which("gcc") is None, + reason="CMake, gfortran, and gcc are required", +) +def test_use_prik_cmake_keeps_native_flags_target_local(tmp_path: Path): + project = tmp_path / "shared native source" + project.mkdir() + source = project / "common.f90" + source.write_text( + "real(8) function common_value(value) result(result)\n" + " real(8), intent(in) :: value\n" + " result = value\n" + "end function common_value\n", + encoding="utf-8", + ) + _write_project( + project, + """set(CMAKE_EXPORT_COMPILE_COMMANDS ON) +prik_add_module(first SOURCES common.f90 FORTRAN_FLAGS -DFIRST_MODULE) +prik_add_module(second SOURCES common.f90 FORTRAN_FLAGS -DSECOND_MODULE) +""", + ) + build = project / "build" + _configure_and_build(project, build, language="fortran") + + commands = json.loads((build / "compile_commands.json").read_text(encoding="utf-8")) + native_commands = [record["command"] for record in commands if Path(record["file"]).resolve() == source.resolve()] + assert len(native_commands) == 2 + assert any("-DFIRST_MODULE" in command and "-DSECOND_MODULE" not in command for command in native_commands) + assert any("-DSECOND_MODULE" in command and "-DFIRST_MODULE" not in command for command in native_commands) + + @pytest.mark.fortran_end_to_end @pytest.mark.skipif( shutil.which("cmake") is None or shutil.which("gfortran") is None or shutil.which("gcc") is None, @@ -370,6 +400,69 @@ def test_generate_cmake_preserves_ordered_native_link_items(tmp_path: Path): assert positions == tuple(sorted(positions)) +@pytest.mark.fortran_end_to_end +def test_generate_cmake_emits_contract_and_linker_languages_separately(tmp_path: Path): + contract = tmp_path / "api.pyi" + contract.write_text( + "from prik.contracts import Float64\ndef add(value: Float64) -> Float64: ...\n", encoding="utf-8" + ) + archive = tmp_path / "libimplementation.a" + archive.touch() + project = tmp_path / "contract project" + _run( + [ + sys.executable, + "-m", + "prik", + "generate", + "--cmake", + "--language", + "c", + str(contract), + "--native-objects", + str(archive), + "--native-linker-language", + "fortran", + "--out-dir", + str(project), + ] + ) + cmake_lists = (project / "CMakeLists.txt").read_text(encoding="utf-8") + assert "CONTRACT" in cmake_lists + assert "NATIVE_LANGUAGE C" in cmake_lists + assert "LINKER_LANGUAGE Fortran" in cmake_lists + + +@pytest.mark.fortran_end_to_end +def test_generate_cmake_preserves_contract_language_with_different_source_language(tmp_path: Path): + contract = tmp_path / "api.pyi" + contract.write_text( + "from prik.contracts import Float64\ndef add(value: Float64) -> Float64: ...\n", encoding="utf-8" + ) + implementation = tmp_path / "implementation.f90" + implementation.write_text("subroutine implementation()\nend subroutine implementation\n", encoding="utf-8") + project = tmp_path / "mixed language contract project" + _run( + [ + sys.executable, + "-m", + "prik", + "generate", + "--cmake", + "--language", + "c", + str(contract), + "--native-fortran-sources", + str(implementation), + "--out-dir", + str(project), + ] + ) + cmake_lists = (project / "CMakeLists.txt").read_text(encoding="utf-8") + assert "NATIVE_LANGUAGE C" in cmake_lists + assert "FORTRAN_SOURCES" in cmake_lists + + @pytest.mark.fortran_end_to_end def test_generate_cmake_keeps_compile_option_ownership_explicit(tmp_path: Path): source = tmp_path / "interface.f90" @@ -752,6 +845,43 @@ def test_use_prik_cmake_rejects_invalid_generation_ownership( assert expected_error_fragment in result.stdout + result.stderr +@pytest.mark.fortran_end_to_end +@pytest.mark.skipif( + shutil.which("cmake") is None or shutil.which("gfortran") is None, reason="CMake and gfortran are required" +) +def test_use_prik_cmake_requires_the_c_language(tmp_path: Path): + project = tmp_path / "fortran-only project" + project.mkdir() + (project / "source.f90").write_text( + "real(8) function source(value) result(result)\n" + " real(8), intent(in) :: value\n" + " result = value\n" + "end function source\n", + encoding="utf-8", + ) + _write_project( + project, + """prik_add_module(source SOURCES source.f90) +""", + languages="Fortran", + ) + result = subprocess.run( + [ + "cmake", + "-S", + str(project), + "-B", + str(project / "build"), + f"-DCMAKE_Fortran_COMPILER={shutil.which('gfortran')}", + ], + env=_environment(), + capture_output=True, + text=True, + ) + assert result.returncode != 0 + assert "PRIK Python extensions require CMake's C language to be enabled" in result.stdout + result.stderr + + @pytest.mark.fortran_end_to_end @pytest.mark.skipif( shutil.which("cmake") is None or shutil.which("gfortran") is None or shutil.which("gcc") is None, @@ -877,6 +1007,89 @@ def test_use_prik_cmake_selects_fortran_linker_for_raw_archive(tmp_path: Path): assert module.raw_add_two(np.int32(5)) == np.int32(7) +@pytest.mark.fortran_end_to_end +@pytest.mark.skipif( + shutil.which("cmake") is None + or shutil.which("gfortran") is None + or shutil.which("gcc") is None + or shutil.which("ar") is None, + reason="CMake, gfortran, gcc, and ar are required", +) +def test_use_prik_cmake_separates_c_contract_and_fortran_linker_languages(tmp_path: Path): + project = tmp_path / "c contract with fortran implementation" + project.mkdir() + (project / "api.pyi").write_text( + "from prik.contracts import Float64\n\ndef add_one(value: Float64) -> Float64: ...\n", + encoding="utf-8", + ) + implementation = project / "implementation.f90" + implementation.write_text( + "real(c_double) function add_one(value) bind(C, name='add_one') result(result)\n" + " use iso_c_binding, only: c_double\n" + " real(c_double), value, intent(in) :: value\n" + " result = value + 1.0_c_double\n" + "end function add_one\n", + encoding="utf-8", + ) + native_object = project / "implementation.o" + archive = project / "libimplementation.a" + _run([shutil.which("gfortran"), "-fPIC", "-c", str(implementation), "-o", str(native_object)]) + _run([shutil.which("ar"), "rcs", str(archive), str(native_object)]) + _write_project( + project, + """prik_add_module( + c_contract + CONTRACT api.pyi + NATIVE_LANGUAGE C + LINKER_LANGUAGE Fortran + LINK_LIBRARIES "${CMAKE_CURRENT_SOURCE_DIR}/libimplementation.a" +) +""", + ) + build = project / "build" + _configure_and_build(project, build, language="fortran") + + module = _import_extension("c_contract", build) + assert module.add_one(np.float64(5.0)) == np.float64(6.0) + + +@pytest.mark.fortran_end_to_end +@pytest.mark.skipif( + shutil.which("cmake") is None or shutil.which("gfortran") is None or shutil.which("gcc") is None, + reason="CMake, gfortran, and gcc are required", +) +def test_use_prik_cmake_allows_contract_language_to_differ_from_source_language(tmp_path: Path): + project = tmp_path / "c contract with fortran source" + project.mkdir() + (project / "api.pyi").write_text( + "from prik.contracts import Float64\n\ndef add_two(value: Float64) -> Float64: ...\n", + encoding="utf-8", + ) + (project / "implementation.f90").write_text( + "real(c_double) function add_two(value) bind(C, name='add_two') result(result)\n" + " use iso_c_binding, only: c_double\n" + " real(c_double), value, intent(in) :: value\n" + " result = value + 2.0_c_double\n" + "end function add_two\n", + encoding="utf-8", + ) + _write_project( + project, + """prik_add_module( + c_contract_source + CONTRACT api.pyi + NATIVE_LANGUAGE C + FORTRAN_SOURCES implementation.f90 +) +""", + ) + build = project / "build" + _configure_and_build(project, build, language="fortran") + + module = _import_extension("c_contract_source", build) + assert module.add_two(np.float64(5.0)) == np.float64(7.0) + + @pytest.mark.fortran_end_to_end @pytest.mark.skipif( shutil.which("cmake") is None or shutil.which("gfortran") is None or shutil.which("gcc") is None, @@ -963,32 +1176,51 @@ def test_use_prik_cmake_links_a_cmake_discovered_blas_target(tmp_path: Path): @pytest.mark.fortran_end_to_end @pytest.mark.slow @pytest.mark.skipif( - shutil.which("cmake") is None - or shutil.which("gfortran") is None - or shutil.which("gcc") is None - or not SETUPTOOLS_SUPPORTS_PROJECT_DATA_FILES, - reason="CMake, compilers, and setuptools 77 or newer are required", + shutil.which("cmake") is None or shutil.which("gfortran") is None or shutil.which("gcc") is None, + reason="CMake, gfortran, and gcc are required", ) def test_installed_wheel_discovers_and_builds_with_use_prik(tmp_path: Path): distribution_dir = tmp_path / "dist" - _run( + clean_environment = os.environ.copy() + clean_environment.pop("PYTHONPATH", None) + wheel_build = subprocess.run( [ sys.executable, "-m", - "build", - "--wheel", - "--no-isolation", - "--outdir", + "pip", + "wheel", + "--no-deps", + "--wheel-dir", str(distribution_dir), + ".", ], cwd=REPOSITORY_ROOT, + env=clean_environment, + capture_output=True, + text=True, ) - wheel = next(distribution_dir.glob("prik-*.whl")) + if wheel_build.returncode != 0: + wheel_output = wheel_build.stderr.strip() or wheel_build.stdout.strip() + unavailable_markers = ( + "No module named pip", + "No module named build", + "No matching distribution found", + "Could not find a version that satisfies", + "Could not fetch URL", + "Temporary failure in name resolution", + "Network is unreachable", + "Connection timed out", + ) + if any(marker.lower() in wheel_output.lower() for marker in unavailable_markers): + pytest.skip(f"isolated wheel construction is unavailable: {wheel_output}") + pytest.fail(f"isolated wheel construction failed:\n{wheel_output}") + wheels = tuple(distribution_dir.glob("prik-*.whl")) + if not wheels: + pytest.skip("isolated wheel construction produced no wheel") + wheel = wheels[0] environment_dir = tmp_path / "installed" venv.EnvBuilder(with_pip=True, system_site_packages=True).create(environment_dir) installed_python = environment_dir / "bin" / "python" - clean_environment = os.environ.copy() - clean_environment.pop("PYTHONPATH", None) _run( [str(installed_python), "-m", "pip", "install", "--no-deps", str(wheel)], environment=clean_environment, From d049a0b2b778a0eabcf4ec57198e2bc01dec0f5a Mon Sep 17 00:00:00 2001 From: said Date: Thu, 10 Sep 2026 19:39:35 +0100 Subject: [PATCH 04/15] codex: fix CMake dependency usage and LTO --- CHANGELOG.md | 4 ++ cmake/UsePRIK.cmake | 5 +++ docs/developer/packages/pipeline.md | 4 +- docs/user/guide/cmake.md | 8 +++- prik/cmake.py | 19 ++++++---- .../building/end_to_end/test_cmake_builds.py | 38 ++++++++++++++++++- 6 files changed, 68 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 217a46d2b..996838f9a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,10 @@ release tags add a leading `v` to the package version. final `LINKER_LANGUAGE`, keep native compilation flags target-local, and report a clear error when CMake's C language is not enabled. +- CMake dependency targets now propagate their native compile usage + requirements, and standalone `--cmake --lto` initializes IPO for native and + generated targets. + - `prik-build.json` schema 5 records generated/native compilation-unit ABI flags and explicit native linker-language requirements. diff --git a/cmake/UsePRIK.cmake b/cmake/UsePRIK.cmake index 80b27301d..3b3b0ad96 100644 --- a/cmake/UsePRIK.cmake +++ b/cmake/UsePRIK.cmake @@ -435,6 +435,11 @@ function(prik_add_module name) endif() add_library("${_prik_native_target}" OBJECT ${_prik_native_target_sources}) set_target_properties("${_prik_native_target}" PROPERTIES POSITION_INDEPENDENT_CODE ON) + foreach(_prik_library IN LISTS PRIK_LINK_LIBRARIES) + if(TARGET "${_prik_library}") + target_link_libraries("${_prik_native_target}" PRIVATE "${_prik_library}") + endif() + endforeach() if(_prik_required_linker_language STREQUAL "fortran") set_target_properties( "${_prik_native_target}" PROPERTIES Fortran_MODULE_DIRECTORY "${_prik_output_dir}" diff --git a/docs/developer/packages/pipeline.md b/docs/developer/packages/pipeline.md index 25962ae45..7dd813ef3 100644 --- a/docs/developer/packages/pipeline.md +++ b/docs/developer/packages/pipeline.md @@ -123,7 +123,9 @@ lossless boundary used by `UsePRIK.cmake` for regeneration, linker-driver selection, and target-local compilation. CMake places native units in a private per-module object target so source properties cannot leak between PRIK extension targets; generated sources remain on the Python extension -target. +target. CMake dependency targets stay attached to the extension for linking +and are also attached to that object target when present, so their usage +requirements reach native compilation. `WrapperBuildResult` and saved `.pyi` manifests report each generated native group's kind, language, member keys, and source paths, so zero-source, diff --git a/docs/user/guide/cmake.md b/docs/user/guide/cmake.md index 203435b1d..a48265a01 100644 --- a/docs/user/guide/cmake.md +++ b/docs/user/guide/cmake.md @@ -100,7 +100,10 @@ interoperability option when compatibility with prebuilt objects requires it. Only mandatory ABI flags are exported from PRIK's plan; recommended compiler profile options remain the CMake toolchain's responsibility. CMake build type, debug, and interprocedural-optimization settings remain -normal CMake target properties; `PRIK_ARGS` rejects compiler and compilation +normal CMake target properties; set +`CMAKE_INTERPROCEDURAL_OPTIMIZATION` before `prik_add_module()` when IPO should +cover both native and generated sources. Standalone `--cmake --lto` emits that +initializer automatically. `PRIK_ARGS` rejects compiler and compilation options that would bypass those target settings. Use `NO_COMPILE_INPUT_SOURCES` when `SOURCES` supplies only the public @@ -138,6 +141,9 @@ prik_add_module( The same form accepts normal project targets such as `native_math` and `OpenMP::OpenMP_Fortran`; they remain target-oriented CMake link inputs. +When a linked entry is a CMake target, its compile and include usage +requirements also reach PRIK's private native object target. Raw library paths +retain link behavior but do not provide CMake usage requirements. Normal Fortran sources and targets carry their link-language requirements through CMake. For a raw archive or shared library whose language is otherwise opaque, add `LINKER_LANGUAGE Fortran`; PRIK records that requirement in its diff --git a/prik/cmake.py b/prik/cmake.py index 655c21d9d..5c48694da 100644 --- a/prik/cmake.py +++ b/prik/cmake.py @@ -196,8 +196,8 @@ def _module_inputs(*, paths: Iterable[str | Path], args) -> _CMakeModuleInputs: ) -def _project_preamble(*, module_name: str, languages: str) -> list[str]: - return [ +def _project_preamble(*, module_name: str, languages: str, lto: bool = False) -> list[str]: + lines = [ "cmake_minimum_required(VERSION 3.20)", "", f"project({module_name} LANGUAGES {languages})", @@ -221,9 +221,16 @@ def _project_preamble(*, module_name: str, languages: str) -> list[str]: 'list(APPEND CMAKE_MODULE_PATH "${PRIK_CMAKE_MODULE_DIR}")', "include(UsePRIK)", "", - "prik_add_module(", - f" {module_name}", ] + if lto: + lines.extend(("set(CMAKE_INTERPROCEDURAL_OPTIMIZATION TRUE)", "")) + lines.extend( + [ + "prik_add_module(", + f" {module_name}", + ] + ) + return lines def _append_source_declarations( @@ -293,7 +300,7 @@ def write_cmake_project( if language == "fortran" or inputs.native_fortran or args.native_linker_language == "fortran" else "C" ) - lines = _project_preamble(module_name=inputs.module_name, languages=project_languages) + lines = _project_preamble(module_name=inputs.module_name, languages=project_languages, lto=args.lto) _append_source_declarations( lines, inputs, @@ -303,8 +310,6 @@ def write_cmake_project( ) _append_build_options(lines, args=args, project_dir=project_dir, native_link_items=native_link_items) lines.append(")") - if args.lto: - lines.extend(("", f"set_property(TARGET {inputs.module_name} PROPERTY INTERPROCEDURAL_OPTIMIZATION TRUE)")) cmake_lists = project_dir / "CMakeLists.txt" cmake_lists.write_text("\n".join(lines) + "\n", encoding="utf-8") diff --git a/tests/fortran/infrastructure/building/end_to_end/test_cmake_builds.py b/tests/fortran/infrastructure/building/end_to_end/test_cmake_builds.py index 6ac3bb142..3d076a7f8 100644 --- a/tests/fortran/infrastructure/building/end_to_end/test_cmake_builds.py +++ b/tests/fortran/infrastructure/building/end_to_end/test_cmake_builds.py @@ -499,7 +499,10 @@ def test_generate_cmake_keeps_compile_option_ownership_explicit(tmp_path: Path): assert 'WRAPPER_C_FLAGS\n "-DPRIK_WRAPPER_C"' in cmake_lists assert "LINKER_LANGUAGE Fortran" in cmake_lists assert "NO_STANDARD_LOGICALS" in cmake_lists - assert "INTERPROCEDURAL_OPTIMIZATION TRUE" in cmake_lists + lto_initializer = "set(CMAKE_INTERPROCEDURAL_OPTIMIZATION TRUE)" + assert lto_initializer in cmake_lists + assert cmake_lists.index(lto_initializer) < cmake_lists.index("prik_add_module(") + assert "set_property(TARGET explicit_options PROPERTY INTERPROCEDURAL_OPTIMIZATION TRUE)" not in cmake_lists assert "--compiler" not in cmake_lists @@ -797,6 +800,39 @@ def test_use_prik_cmake_builds_c_source_with_include_directory_and_flag(tmp_path assert module.c_add(np.float64(2.0), np.float64(3.0)) == np.float64(6.0) +@pytest.mark.fortran_end_to_end +@pytest.mark.skipif(shutil.which("cmake") is None or shutil.which("gcc") is None, reason="CMake and gcc are required") +def test_use_prik_cmake_propagates_dependency_usage_to_native_objects(tmp_path: Path): + project = tmp_path / "native dependency usage" + project.mkdir() + (project / "interface.c").write_text("double dependency_add(double value);\n", encoding="utf-8") + (project / "implementation.c").write_text( + "#ifndef PRIK_REQUIRED_DEFINE\n" + "#error missing dependency compile definition\n" + "#endif\n" + "double dependency_add(double value) { return value + 1.0; }\n", + encoding="utf-8", + ) + _write_project( + project, + """add_library(native_dependency INTERFACE) +target_compile_definitions(native_dependency INTERFACE PRIK_REQUIRED_DEFINE) +prik_add_module( + dependency_usage + SOURCES interface.c + C_SOURCES implementation.c + LINK_LIBRARIES native_dependency +) +""", + languages="C", + ) + build = project / "build" + _configure_and_build(project, build, language="c") + + module = _import_extension("dependency_usage", build) + assert module.dependency_add(np.float64(2.0)) == np.float64(3.0) + + @pytest.mark.fortran_end_to_end @pytest.mark.skipif( shutil.which("cmake") is None or shutil.which("gfortran") is None or shutil.which("gcc") is None, From 96bf11302890b05dfd2d7a80f9c9d813f820f87e Mon Sep 17 00:00:00 2001 From: said Date: Thu, 10 Sep 2026 20:03:26 +0100 Subject: [PATCH 05/15] codex: harden CMake language validation --- cmake/UsePRIK.cmake | 25 +++++++++++-------- docs/user/reference/cli-commands.md | 5 ++-- prik/cli.py | 2 +- .../building/end_to_end/test_cmake_builds.py | 1 + 4 files changed, 20 insertions(+), 13 deletions(-) diff --git a/cmake/UsePRIK.cmake b/cmake/UsePRIK.cmake index 3b3b0ad96..a4ee6ac13 100644 --- a/cmake/UsePRIK.cmake +++ b/cmake/UsePRIK.cmake @@ -196,7 +196,10 @@ function(prik_add_module name) endif() _prik_validate_args(PRIK_PRIK_ARGS) - if(NOT CMAKE_C_COMPILER OR NOT CMAKE_C_COMPILER_ID) + get_property(_prik_enabled_languages GLOBAL PROPERTY ENABLED_LANGUAGES) + list(FIND _prik_enabled_languages C _prik_c_language_index) + list(FIND _prik_enabled_languages Fortran _prik_fortran_language_index) + if(_prik_c_language_index EQUAL -1 OR NOT CMAKE_C_COMPILER) message( FATAL_ERROR "PRIK Python extensions require CMake's C language to be enabled. " @@ -301,25 +304,27 @@ function(prik_add_module name) _prik_validate_source_suffixes(fortran _prik_native_fortran_sources) _prik_validate_source_suffixes(c _prik_native_c_sources) - if(_prik_linker_language STREQUAL "fortran" AND NOT CMAKE_Fortran_COMPILER) - message( - FATAL_ERROR - "PRIK module ${name} requires CMake's Fortran language to be enabled for LINKER_LANGUAGE Fortran. " - "Use project(... LANGUAGES C Fortran) or enable_language(Fortran)." - ) + if(_prik_linker_language STREQUAL "fortran") + if(_prik_fortran_language_index EQUAL -1 OR NOT CMAKE_Fortran_COMPILER) + message( + FATAL_ERROR + "PRIK module ${name} requires CMake's Fortran language to be enabled for LINKER_LANGUAGE Fortran. " + "Use project(... LANGUAGES C Fortran) or enable_language(Fortran)." + ) + endif() endif() set(_prik_output_dir "${CMAKE_CURRENT_BINARY_DIR}/prik/${name}") file(MAKE_DIRECTORY "${_prik_output_dir}") if(_prik_language STREQUAL "fortran") + if(_prik_fortran_language_index EQUAL -1 OR NOT CMAKE_Fortran_COMPILER) + message(FATAL_ERROR "PRIK module ${name} requires CMake to enable Fortran") + endif() set(_prik_analysis_compiler "${CMAKE_Fortran_COMPILER}") else() set(_prik_analysis_compiler "${CMAKE_C_COMPILER}") endif() - if(NOT _prik_analysis_compiler) - message(FATAL_ERROR "PRIK module ${name} requires CMake to enable ${_prik_language}") - endif() set(_prik_generate_command "${Python_EXECUTABLE}" -m prik generate --sources) if(_prik_contract) diff --git a/docs/user/reference/cli-commands.md b/docs/user/reference/cli-commands.md index eb01a116e..c6738638f 100644 --- a/docs/user/reference/cli-commands.md +++ b/docs/user/reference/cli-commands.md @@ -100,7 +100,7 @@ least one explicit native input: `--native-fortran-sources`, `--native-c-sources | `--native-link-item KIND:VALUE ...` | Ordered link items. `KIND` is `object`, `archive`, `shared-library`, `library`, or `arg`. | | `--native-linker-language {c,fortran}` | Requires the named final linker language when prebuilt inputs do not carry it. | | `--native-library-dir DIR ...` | Library search directories and runtime paths. | -| `--lto` | Enables link-time optimization for Fortran and C builds by adding `-flto` to generated and native compilation and to the extension link. | +| `--lto` | Enables link-time optimization for generated and native compilation and the extension link. Direct builds add `-flto`; generated CMake projects initialize CMake IPO. | | `--collision-adapter NAME ...` | Calls native symbol `NAME` through a forwarder defined in a separate translation unit, so the binding never declares an identifier its own headers already declare. | | `--collision-adapter-all` | Applies `--collision-adapter` to every eligible C function in the build. | | `--positional-only` | For Fortran and C, exposes every wrapper whose arguments are all required as positional-only, renaming them `arg0`..`argN`. | @@ -231,7 +231,8 @@ In CMake mode, `--compiler` and `--wrapper-compiler-debug` are rejected: CMake's selected compiler and build configuration own those choices. Native and generated-wrapper flag options remain distinct in the generated helper call, `--no-standard-logicals` maps to PRIK's CMake compilation plan, and -`--lto` maps to CMake's interprocedural-optimization target property. +`--lto` initializes CMake interprocedural optimization before PRIK creates its +native and extension targets. With no `--out`, `generate --pyi` prints every generated contract. For Fortran, `--out PATH` names a package directory containing `__init__.pyi` and any module leaves. For C, it names the single output `.pyi` diff --git a/prik/cli.py b/prik/cli.py index 86cc92a31..a9ca96ef2 100644 --- a/prik/cli.py +++ b/prik/cli.py @@ -2363,7 +2363,7 @@ def _add_extension_link_options(group: argparse._ArgumentGroup) -> None: group.add_argument( "--lto", action="store_true", - help="Add -flto to generated and native compilation and to the extension link", + help="Enable link-time optimization for generated and native compilation and the extension link", ) group.add_argument( "--collision-adapter", diff --git a/tests/fortran/infrastructure/building/end_to_end/test_cmake_builds.py b/tests/fortran/infrastructure/building/end_to_end/test_cmake_builds.py index 3d076a7f8..245ca7aa1 100644 --- a/tests/fortran/infrastructure/building/end_to_end/test_cmake_builds.py +++ b/tests/fortran/infrastructure/building/end_to_end/test_cmake_builds.py @@ -908,6 +908,7 @@ def test_use_prik_cmake_requires_the_c_language(tmp_path: Path): str(project), "-B", str(project / "build"), + f"-DCMAKE_C_COMPILER={shutil.which('gcc')}", f"-DCMAKE_Fortran_COMPILER={shutil.which('gfortran')}", ], env=_environment(), From 52aa92bf3ae14ddb0387dc1ee67120a0e62c5262 Mon Sep 17 00:00:00 2001 From: said Date: Thu, 10 Sep 2026 21:50:00 +0100 Subject: [PATCH 06/15] Keep CMake link inputs typed and library dirs on the runtime path Two narrow correctness fixes in the standalone CLI -> CMake translation and its CMake target properties; the pipeline stages themselves are unchanged. generate --cmake resolved prebuilt objects, archives, and shared libraries to absolute paths and then rewrote every absolute item back to a relative string, so a real file path reached target_link_libraries() as a bare token and the linker read it as a library name (-l../lib/libnative.a). Path-backed items now render as "${CMAKE_CURRENT_LIST_DIR}/", keeping the three categories distinct - object/archive/shared library to a path, named library to a name, arg: to a linker argument - in their original order. --native-library-dir became LINK_OPTIONS "-L", which carries no runtime meaning, so an extension could link and then fail to import. prik_add_module() gains LIBRARY_DIRS, mapped to target_link_directories() plus the target's BUILD_RPATH, and generate --cmake emits LIBRARY_DIRS instead of a raw -L. INSTALL_RPATH stays under project control. Also fixes a bare Python float in the installed-wheel CMake test: PRIK's documented scalar policy requires numpy.float64, and CI reached that assertion once the isolated wheel install succeeded. Changed: prik/cmake.py (link-item rendering, LIBRARY_DIRS emission), cmake/UsePRIK.cmake (LIBRARY_DIRS argument, link directories, BUILD_RPATH), CMake guide and CLI reference docs, CHANGELOG. Tests: two end-to-end regressions in tests/fortran/infrastructure/building/ end_to_end/test_cmake_builds.py that configure, build, import, and call - one linking a real prebuilt .o and .a from a sibling directory, one importing an extension backed by a shared library in a non-system directory with no loader path able to resolve it. The ordered-link-item ordering test is kept. Verified: all five suite roots green (fortran -m "not real_library" 2534, c 646, docs 745, tools/workflows 64) plus the CI toolchain-smoke lane (8) and a CMake-file rerun with LD_LIBRARY_PATH set. Static gate clean: ruff check and format, version check, bandit, vulture, radon policy. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01NrtPVjqMKNLJ1cYsteWiKG --- CHANGELOG.md | 11 ++ cmake/UsePRIK.cmake | 9 + docs/user/guide/cmake.md | 35 +++- docs/user/reference/cli-commands.md | 9 +- prik/cmake.py | 33 +++- .../building/end_to_end/test_cmake_builds.py | 175 +++++++++++++++++- 6 files changed, 258 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 996838f9a..ba8127fca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,17 @@ release tags add a leading `v` to the package version. requirements, and standalone `--cmake --lto` initializes IPO for native and generated targets. +- `prik generate --cmake` keeps each native link input in its own CMake + category: prebuilt objects, archives, and shared libraries stay filesystem + paths in `LINK_LIBRARIES` instead of becoming ambiguous relative tokens, + while library names and linker arguments keep their meaning and order. + +- `prik_add_module()` accepts `LIBRARY_DIRS`, mapping it to + `target_link_directories()` and the extension's `BUILD_RPATH`. + `generate --cmake` translates `--native-library-dir` into `LIBRARY_DIRS`, so + a shared native library outside the system search path is found both at link + time and on import without `LD_LIBRARY_PATH`. + - `prik-build.json` schema 5 records generated/native compilation-unit ABI flags and explicit native linker-language requirements. diff --git a/cmake/UsePRIK.cmake b/cmake/UsePRIK.cmake index a4ee6ac13..f3e5ace63 100644 --- a/cmake/UsePRIK.cmake +++ b/cmake/UsePRIK.cmake @@ -187,6 +187,7 @@ function(prik_add_module name) WRAPPER_FORTRAN_FLAGS WRAPPER_C_FLAGS LINK_LIBRARIES + LIBRARY_DIRS LINK_OPTIONS PRIK_ARGS ) @@ -226,6 +227,7 @@ function(prik_add_module name) _prik_make_absolute_paths(_prik_c_sources ${PRIK_C_SOURCES}) _prik_make_absolute_paths(_prik_include_dirs ${PRIK_INCLUDE_DIRS}) _prik_make_absolute_paths(_prik_module_dirs ${PRIK_MODULE_DIRS}) + _prik_make_absolute_paths(_prik_library_dirs ${PRIK_LIBRARY_DIRS}) if(PRIK_LINKER_LANGUAGE) string(TOLOWER "${PRIK_LINKER_LANGUAGE}" _prik_linker_language) @@ -545,6 +547,13 @@ function(prik_add_module name) if(PRIK_LINK_LIBRARIES) target_link_libraries("${name}" PRIVATE ${PRIK_LINK_LIBRARIES}) endif() + if(_prik_library_dirs) + # A library directory is a link-time search path and, for a shared + # native library, the runtime search path the extension needs on + # import. INSTALL_RPATH stays under project control. + target_link_directories("${name}" PRIVATE ${_prik_library_dirs}) + set_property(TARGET "${name}" APPEND PROPERTY BUILD_RPATH ${_prik_library_dirs}) + endif() if(_prik_extension_link_flags OR PRIK_LINK_OPTIONS) target_link_options("${name}" PRIVATE ${_prik_extension_link_flags} ${PRIK_LINK_OPTIONS}) endif() diff --git a/docs/user/guide/cmake.md b/docs/user/guide/cmake.md index a48265a01..6794b8f43 100644 --- a/docs/user/guide/cmake.md +++ b/docs/user/guide/cmake.md @@ -65,7 +65,8 @@ cmake --build build `prik_add_module()` also accepts `SOURCES` for source-first input, `CONTRACT` with `FORTRAN_SOURCES` or `C_SOURCES` for an authored semantic `.pyi`, `INCLUDE_DIRS`, `MODULE_DIRS`, native and generated-source compile flag groups, -`LINK_LIBRARIES`, `LINK_OPTIONS`, and additional generation-only `PRIK_ARGS`. +`LINK_LIBRARIES`, `LIBRARY_DIRS`, `LINK_OPTIONS`, and additional +generation-only `PRIK_ARGS`. For a contract backed only by opaque native inputs, use `NATIVE_LANGUAGE` to state the contract ABI language and `LINKER_LANGUAGE` to state the final CMake linker driver independently: @@ -144,6 +145,28 @@ The same form accepts normal project targets such as `native_math` and When a linked entry is a CMake target, its compile and include usage requirements also reach PRIK's private native object target. Raw library paths retain link behavior but do not provide CMake usage requirements. + +`LINK_LIBRARIES` keeps each entry's own CMake meaning: a path to an object, +archive, or shared library stays a file path, a plain name stays a library +name, and a `-Wl,...` entry stays a linker argument in the position it was +given. + +`LIBRARY_DIRS` names directories that hold native libraries linked by name. +PRIK gives them to `target_link_directories()` and appends them to the +extension's `BUILD_RPATH`, so a shared native library outside the system +search path is found both when CMake links the extension and when Python +imports it from the build tree. `INSTALL_RPATH` stays under normal project +control: + +```cmake +prik_add_module( + physics + FORTRAN_SOURCES solver.f90 + LINK_LIBRARIES nativefoo + LIBRARY_DIRS "${CMAKE_CURRENT_LIST_DIR}/vendor/lib" +) +``` + Normal Fortran sources and targets carry their link-language requirements through CMake. For a raw archive or shared library whose language is otherwise opaque, add `LINKER_LANGUAGE Fortran`; PRIK records that requirement in its @@ -173,3 +196,13 @@ The generated `CMakeLists.txt` loads `UsePRIK.cmake` and calls project; `prik generate --cmake` creates a standalone CMake project that uses that same helper. `--native-linker-language fortran` emits the explicit raw library annotation when standalone input requires the Fortran linker. + +Native link inputs keep the meaning they have on the command line. +`--native-objects` and `--native-link-item object:`, `archive:`, and +`shared-library:` become `LINK_LIBRARIES` file paths written against +`CMAKE_CURRENT_LIST_DIR`, so the generated project stays readable and moves +with its inputs; `--native-library` becomes a library name and +`--native-link-item arg:` a linker argument, all in their original order. +`--native-library-dir` becomes `LIBRARY_DIRS`, which keeps the CLI meaning of +that option: a link-time search directory that is also a runtime search path +for the built extension. diff --git a/docs/user/reference/cli-commands.md b/docs/user/reference/cli-commands.md index c6738638f..4660aa1a4 100644 --- a/docs/user/reference/cli-commands.md +++ b/docs/user/reference/cli-commands.md @@ -99,7 +99,7 @@ least one explicit native input: `--native-fortran-sources`, `--native-c-sources | `--native-library NAME ...` | Links system libraries by name — `--native-library openblas` passes `-lopenblas`. | | `--native-link-item KIND:VALUE ...` | Ordered link items. `KIND` is `object`, `archive`, `shared-library`, `library`, or `arg`. | | `--native-linker-language {c,fortran}` | Requires the named final linker language when prebuilt inputs do not carry it. | -| `--native-library-dir DIR ...` | Library search directories and runtime paths. | +| `--native-library-dir DIR ...` | Library search directories and runtime paths. Direct builds add `-L` and `-rpath`; generated CMake projects emit `LIBRARY_DIRS`. | | `--lto` | Enables link-time optimization for generated and native compilation and the extension link. Direct builds add `-flto`; generated CMake projects initialize CMake IPO. | | `--collision-adapter NAME ...` | Calls native symbol `NAME` through a forwarder defined in a separate translation unit, so the binding never declares an identifier its own headers already declare. | | `--collision-adapter-all` | Applies `--collision-adapter` to every eligible C function in the build. | @@ -232,7 +232,12 @@ CMake's selected compiler and build configuration own those choices. Native and generated-wrapper flag options remain distinct in the generated helper call, `--no-standard-logicals` maps to PRIK's CMake compilation plan, and `--lto` initializes CMake interprocedural optimization before PRIK creates its -native and extension targets. +native and extension targets. Native link inputs keep their category and +order: `--native-objects` and path-valued `--native-link-item` kinds become +`LINK_LIBRARIES` file paths, `--native-library` a library name, +`--native-link-item arg:` a linker argument, and `--native-library-dir` a +`LIBRARY_DIRS` entry that is both a link search directory and a build runtime +path. With no `--out`, `generate --pyi` prints every generated contract. For Fortran, `--out PATH` names a package directory containing `__init__.pyi` and any module leaves. For C, it names the single output `.pyi` diff --git a/prik/cmake.py b/prik/cmake.py index 5c48694da..5410b401a 100644 --- a/prik/cmake.py +++ b/prik/cmake.py @@ -136,17 +136,32 @@ def add_option(option: str, value: object | None = None) -> None: return tuple(result) +def _link_item_path(path: Path, base: Path) -> str: + """Keep a prebuilt link input recognizable to CMake as a filesystem path. + + ``target_link_libraries()`` distinguishes file paths from library names and + linker flags by the item's own text, so a bare relative path would leave + that category ambiguous. Rooting the path in ``CMAKE_CURRENT_LIST_DIR`` + keeps the generated project relocatable alongside its inputs. + """ + try: + relative = _relative_path(path, base) + except ValueError: # pragma: no cover - only reachable across Windows drives + return path.as_posix() + return "${CMAKE_CURRENT_LIST_DIR}/" + relative + + def _link_values( args, *, base: Path, native_link_items: Iterable[dict[str, object]], -) -> tuple[tuple[str, ...], tuple[str, ...]]: - libraries = [str(Path(path).resolve()) for path in (args.native_objects or ())] +) -> tuple[str, ...]: + libraries = [_link_item_path(Path(path).resolve(), base) for path in (args.native_objects or ())] for item in native_link_items: kind = item["kind"] if kind in {"object", "archive", "shared_library"}: - libraries.append(str(Path(str(item["path"])).resolve())) + libraries.append(_link_item_path(Path(str(item["path"])).resolve(), base)) elif kind == "named_library": libraries.append(str(item["name"])) elif kind == "linker_argument": @@ -156,10 +171,7 @@ def _link_values( else: # pragma: no cover - the CLI normalizer rejects this first. raise ValueError(f"Unsupported native link item kind: {kind!r}") libraries.extend(_flag_values(args.native_libraries)) - options: list[str] = [f"-L{Path(path).resolve()}" for path in (args.native_library_dirs or ())] - return tuple( - _relative_path(Path(value), base) if Path(value).is_absolute() else value for value in libraries - ), tuple(options) + return tuple(libraries) def _module_inputs(*, paths: Iterable[str | Path], args) -> _CMakeModuleInputs: @@ -268,13 +280,16 @@ def _append_build_options( c_flags = _flag_values(args.native_c_compile_flags) wrapper_fortran_flags = _flag_values(args.wrapper_fortran_flags) wrapper_c_flags = _flag_values(args.wrapper_c_flags) - libraries, link_options = _link_values(args, base=project_dir, native_link_items=native_link_items) + libraries = _link_values(args, base=project_dir, native_link_items=native_link_items) _append_values(lines, "FORTRAN_FLAGS", fortran_flags) _append_values(lines, "C_FLAGS", c_flags) _append_values(lines, "WRAPPER_FORTRAN_FLAGS", wrapper_fortran_flags) _append_values(lines, "WRAPPER_C_FLAGS", wrapper_c_flags) _append_values(lines, "LINK_LIBRARIES", libraries) - _append_values(lines, "LINK_OPTIONS", link_options) + # A native library directory is both a link-time search path and a runtime + # search path, so it reaches CMake as a link directory rather than a raw + # -L flag that carries no runtime meaning. + _append_block(lines, "LIBRARY_DIRS", _absolute_paths(args.native_library_dirs or ()), base=project_dir) if args.native_linker_language: lines.append(f" LINKER_LANGUAGE {args.native_linker_language.capitalize()}") if not args.standard_logicals: diff --git a/tests/fortran/infrastructure/building/end_to_end/test_cmake_builds.py b/tests/fortran/infrastructure/building/end_to_end/test_cmake_builds.py index 245ca7aa1..c1d5d11f7 100644 --- a/tests/fortran/infrastructure/building/end_to_end/test_cmake_builds.py +++ b/tests/fortran/infrastructure/building/end_to_end/test_cmake_builds.py @@ -90,6 +90,26 @@ def _import_extension(module_name: str, build: Path): sys.path.remove(str(artifacts[0].parent)) +def _call_with_unassisted_loader(module_name: str, build: Path, expression: str, *, native_library: Path) -> str: + """Import the built extension with no loader path able to find its native library. + + The interpreter keeps whatever loader environment it was started with, since + a shared-libpython build needs it, but that environment is asserted not to + resolve ``native_library``. Only the extension's own build rpath can. + """ + artifacts = tuple(build.rglob(f"{module_name}*.so")) + assert artifacts, f"no CMake extension artifact in {build}" + environment = _environment() + for variable in ("LD_LIBRARY_PATH", "DYLD_LIBRARY_PATH", "DYLD_FALLBACK_LIBRARY_PATH"): + searched = tuple(entry for entry in environment.get(variable, "").split(os.pathsep) if entry) + assert not any((Path(entry) / native_library.name).exists() for entry in searched), ( + f"{variable} already resolves {native_library.name}, so the import would not prove a build rpath" + ) + environment["PYTHONPATH"] = str(artifacts[0].parent) + os.pathsep + environment["PYTHONPATH"] + program = f"import numpy, {module_name}\nprint({expression})\n" + return _run([sys.executable, "-c", program], environment=environment).stdout.strip() + + def _write_project(project: Path, body: str, *, languages: str = "C Fortran") -> None: project.mkdir(parents=True, exist_ok=True) (project / "CMakeLists.txt").write_text( @@ -392,7 +412,7 @@ def test_generate_cmake_preserves_ordered_native_link_items(tmp_path: Path): cmake_lists = (project / "CMakeLists.txt").read_text(encoding="utf-8") ordered_items = ( '"-Wl,--start-group"', - f'"{Path(os.path.relpath(archive, project)).as_posix()}"', + f'"${{CMAKE_CURRENT_LIST_DIR}}/{Path(os.path.relpath(archive, project)).as_posix()}"', '"ordered"', '"-Wl,--end-group"', ) @@ -400,6 +420,156 @@ def test_generate_cmake_preserves_ordered_native_link_items(tmp_path: Path): assert positions == tuple(sorted(positions)) +@pytest.mark.fortran_end_to_end +@pytest.mark.skipif( + shutil.which("cmake") is None or shutil.which("gfortran") is None or shutil.which("gcc") is None, + reason="CMake, gfortran, and gcc are required", +) +def test_generate_cmake_links_prebuilt_object_and_archive_paths(tmp_path: Path): + prebuilt = tmp_path / "prebuilt native inputs" + prebuilt.mkdir() + (tmp_path / "scaled.f90").write_text( + """real(8) function scaled(x) result(y) + real(8), intent(in) :: x + y = x * 3.0d0 +end function scaled +""", + encoding="utf-8", + ) + (tmp_path / "shifted.f90").write_text( + """real(8) function shifted(x) result(y) + real(8), intent(in) :: x + y = x + 7.0d0 +end function shifted +""", + encoding="utf-8", + ) + archive_object = prebuilt / "scaled.o" + archive = prebuilt / "libscaled.a" + linked_object = prebuilt / "shifted.o" + _run(["gfortran", "-c", "-fPIC", "-o", str(archive_object), str(tmp_path / "scaled.f90")]) + _run(["ar", "rcs", str(archive), str(archive_object)]) + _run(["gfortran", "-c", "-fPIC", "-o", str(linked_object), str(tmp_path / "shifted.f90")]) + archive_object.unlink() + + project = tmp_path / "prebuilt inputs project" + project.mkdir() + (project / "interface.f90").write_text( + """real(8) function scaled(x) result(y) + real(8), intent(in) :: x + y = x +end function scaled + +real(8) function shifted(x) result(y) + real(8), intent(in) :: x + y = x +end function shifted +""", + encoding="utf-8", + ) + _run( + [ + sys.executable, + "-m", + "prik", + "generate", + "--cmake", + str(project / "interface.f90"), + "--module-name", + "prebuilt_inputs", + "--no-compile-input-sources", + "--native-objects", + str(linked_object), + "--native-link-item", + f"archive:{archive}", + "--native-linker-language", + "fortran", + "--out-dir", + str(project), + ] + ) + + cmake_lists = (project / "CMakeLists.txt").read_text(encoding="utf-8") + for prebuilt_path in (linked_object, archive): + relative = Path(os.path.relpath(prebuilt_path, project)).as_posix() + assert f'"${{CMAKE_CURRENT_LIST_DIR}}/{relative}"' in cmake_lists + + build = project / "build" + _configure_and_build(project, build, language="fortran") + module = _import_extension("prebuilt_inputs", build) + assert module.scaled(np.float64(4.0)) == np.float64(12.0) + assert module.shifted(np.float64(4.0)) == np.float64(11.0) + + +@pytest.mark.fortran_end_to_end +@pytest.mark.skipif( + shutil.which("cmake") is None or shutil.which("gfortran") is None or shutil.which("gcc") is None, + reason="CMake, gfortran, and gcc are required", +) +def test_generate_cmake_native_library_dir_reaches_the_runtime_search_path(tmp_path: Path): + library_dir = tmp_path / "native runtime lib" + library_dir.mkdir() + (tmp_path / "runtime_value.f90").write_text( + """real(8) function runtime_value(x) result(y) + real(8), intent(in) :: x + y = x * 5.0d0 +end function runtime_value +""", + encoding="utf-8", + ) + native_library = library_dir / "libprikruntime.so" + _run(["gfortran", "-shared", "-fPIC", "-o", str(native_library), str(tmp_path / "runtime_value.f90")]) + + project = tmp_path / "runtime rpath project" + project.mkdir() + (project / "interface.f90").write_text( + """real(8) function runtime_value(x) result(y) + real(8), intent(in) :: x + y = x +end function runtime_value +""", + encoding="utf-8", + ) + _run( + [ + sys.executable, + "-m", + "prik", + "generate", + "--cmake", + str(project / "interface.f90"), + "--module-name", + "runtime_rpath", + "--no-compile-input-sources", + "--native-library", + "prikruntime", + "--native-library-dir", + str(library_dir), + "--native-linker-language", + "fortran", + "--out-dir", + str(project), + ] + ) + + cmake_lists = (project / "CMakeLists.txt").read_text(encoding="utf-8") + relative_library_dir = Path(os.path.relpath(library_dir, project)).as_posix() + assert f'LIBRARY_DIRS\n "{relative_library_dir}"' in cmake_lists + # The directory carries CMake link and runtime meaning, so it is not also + # repeated as a bare -L linker flag. + assert "LINK_OPTIONS" not in cmake_lists + + build = project / "build" + _configure_and_build(project, build, language="fortran") + called = _call_with_unassisted_loader( + "runtime_rpath", + build, + "runtime_rpath.runtime_value(numpy.float64(3.0))", + native_library=native_library, + ) + assert called == "15.0" + + @pytest.mark.fortran_end_to_end def test_generate_cmake_emits_contract_and_linker_languages_separately(tmp_path: Path): contract = tmp_path / "api.pyi" @@ -1313,7 +1483,8 @@ def test_installed_wheel_discovers_and_builds_with_use_prik(tmp_path: Path): "-I", "-c", f"import sys; sys.path.insert(0, {str(artifact.parent)!r}); " - "import installed_square; assert installed_square.installed_square(3.0) == 9.0", + "import numpy, installed_square; " + "assert installed_square.installed_square(numpy.float64(3.0)) == 9.0", ], cwd=artifact.parent, environment=clean_environment, From c69a6a85a6e8b3a84fa62096c6038c816e57e1d7 Mon Sep 17 00:00:00 2001 From: said Date: Thu, 10 Sep 2026 22:15:20 +0100 Subject: [PATCH 07/15] Preserve CMake link syntax and reject a C++-typed C suffix Three narrow fixes in the packaged CMake helper; the pipeline stages are unchanged. The native OBJECT target filtered LINK_LIBRARIES through if(TARGET ...), which forwarded "debug Foo optimized Bar" as plain Foo and Bar with their keywords stripped, so both configurations' usage requirements reached the native sources at once, and dropped generator-expression entries entirely because they are not targets. PRIK_LINK_LIBRARIES now passes through unchanged, so CMake applies the caller's own selection rules. The extension target keeps the same LINK_LIBRARIES for the final link. CMake compiles .C as C++ while PRIK plans such a source as C, so the two would disagree about the compiler. prik_add_module() and generate --cmake now reject that suffix with a message naming the reason, and generation fails before a project is written rather than at CMake configure time. A module contributing native Fortran sources now checks that CMake's Fortran language is enabled, instead of failing later with a less direct error. Changed: cmake/UsePRIK.cmake (native link forwarding, suffix validation, Fortran language check), prik/cmake.py (fail-fast .C rejection), CMake guide docs, CHANGELOG. Tests: five regressions in tests/fortran/infrastructure/building/end_to_end/ test_cmake_builds.py - a parametrized Debug/Release pair whose native source #errors when the wrong configuration's definition arrives or the right one is missing, .C rejection at both the helper and CLI level, and the Fortran language diagnostic. All five fail against the pre-fix helper and pass with it. Verified: test_cmake_builds.py plus tests/docs 782 passed, CI toolchain-smoke lane 8 passed, static gate clean (ruff check and format, version check, bandit, vulture, radon policy). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01NrtPVjqMKNLJ1cYsteWiKG --- CHANGELOG.md | 9 ++ cmake/UsePRIK.cmake | 34 +++- docs/user/guide/cmake.md | 25 ++- prik/cmake.py | 16 ++ .../building/end_to_end/test_cmake_builds.py | 152 ++++++++++++++++++ 5 files changed, 224 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ba8127fca..dcbf49a6b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,15 @@ release tags add a leading `v` to the package version. paths in `LINK_LIBRARIES` instead of becoming ambiguous relative tokens, while library names and linker arguments keep their meaning and order. +- CMake native object targets receive `LINK_LIBRARIES` with the caller's link + syntax unchanged, so `debug`/`optimized` keywords and generator-expression + entries keep selecting usage requirements per configuration instead of being + flattened or dropped. + +- CMake mode rejects a `.C` source suffix, which CMake compiles as C++ while + PRIK plans the source as C, and reports a clear error when a module + contributes native Fortran sources without CMake's Fortran language enabled. + - `prik_add_module()` accepts `LIBRARY_DIRS`, mapping it to `target_link_directories()` and the extension's `BUILD_RPATH`. `generate --cmake` translates `--native-library-dir` into `LIBRARY_DIRS`, so diff --git a/cmake/UsePRIK.cmake b/cmake/UsePRIK.cmake index f3e5ace63..56620f192 100644 --- a/cmake/UsePRIK.cmake +++ b/cmake/UsePRIK.cmake @@ -31,13 +31,20 @@ endfunction() function(_prik_validate_source_suffixes language sources) foreach(_source IN LISTS ${sources}) get_filename_component(_suffix "${_source}" LAST_EXT) - string(TOLOWER "${_suffix}" _suffix) + string(TOLOWER "${_suffix}" _lower_suffix) if(language STREQUAL "fortran") - if(NOT _suffix MATCHES "^\\.(f|f03|f08|f77|f90|f95|for|ftn)$") + if(NOT _lower_suffix MATCHES "^\\.(f|f03|f08|f77|f90|f95|for|ftn)$") message(FATAL_ERROR "PRIK Fortran input is not a supported source: ${_source}") endif() - elseif(NOT _suffix STREQUAL ".c") + elseif(NOT _lower_suffix STREQUAL ".c") message(FATAL_ERROR "PRIK C input is not a supported source: ${_source}") + elseif(NOT _suffix STREQUAL ".c") + # PRIK plans the source as C, so CMake must compile it as C too. + message( + FATAL_ERROR + "PRIK C sources used through CMake must use the .c suffix; " + ".C is interpreted as C++ by CMake: ${_source}" + ) endif() endforeach() endfunction() @@ -315,6 +322,15 @@ function(prik_add_module name) ) endif() endif() + if(_prik_native_fortran_sources) + if(_prik_fortran_language_index EQUAL -1 OR NOT CMAKE_Fortran_COMPILER) + message( + FATAL_ERROR + "PRIK module ${name} requires CMake's Fortran language to be enabled for its native Fortran sources. " + "Use project(... LANGUAGES C Fortran) or enable_language(Fortran)." + ) + endif() + endif() set(_prik_output_dir "${CMAKE_CURRENT_BINARY_DIR}/prik/${name}") file(MAKE_DIRECTORY "${_prik_output_dir}") @@ -442,11 +458,13 @@ function(prik_add_module name) endif() add_library("${_prik_native_target}" OBJECT ${_prik_native_target_sources}) set_target_properties("${_prik_native_target}" PROPERTIES POSITION_INDEPENDENT_CODE ON) - foreach(_prik_library IN LISTS PRIK_LINK_LIBRARIES) - if(TARGET "${_prik_library}") - target_link_libraries("${_prik_native_target}" PRIVATE "${_prik_library}") - endif() - endforeach() + if(PRIK_LINK_LIBRARIES) + # Forward the caller's own link syntax so the native sources see the + # usage requirements CMake would give them: debug/optimized keywords + # and generator expressions still select per configuration, which a + # target-only filter would flatten or drop. + target_link_libraries("${_prik_native_target}" PRIVATE ${PRIK_LINK_LIBRARIES}) + endif() if(_prik_required_linker_language STREQUAL "fortran") set_target_properties( "${_prik_native_target}" PROPERTIES Fortran_MODULE_DIRECTORY "${_prik_output_dir}" diff --git a/docs/user/guide/cmake.md b/docs/user/guide/cmake.md index 6794b8f43..b77256596 100644 --- a/docs/user/guide/cmake.md +++ b/docs/user/guide/cmake.md @@ -86,7 +86,13 @@ When native source files use one language, PRIK infers `NATIVE_LANGUAGE` from differs from the implementation source language or when both source languages are present. A source-free contract must state it explicitly. CMake's C language must be enabled because every PRIK extension contains generated C -binding code. +binding code, and Fortran must be enabled whenever the module contributes +Fortran sources. + +C sources must use the lowercase `.c` suffix here. CMake compiles `.C` as C++, +which would not match the C plan PRIK generates for the source, so +`prik_add_module()` and `generate --cmake` reject that suffix instead of +letting the two disagree. The flag groups remain separate: @@ -142,9 +148,20 @@ prik_add_module( The same form accepts normal project targets such as `native_math` and `OpenMP::OpenMP_Fortran`; they remain target-oriented CMake link inputs. -When a linked entry is a CMake target, its compile and include usage -requirements also reach PRIK's private native object target. Raw library paths -retain link behavior but do not provide CMake usage requirements. +`LINK_LIBRARIES` reaches PRIK's private native object target with its own +syntax intact, so a linked target's compile and include usage requirements +apply to the native sources, and `debug`/`optimized` keywords and generator +expressions still select per configuration. Raw library paths retain link +behavior but do not provide CMake usage requirements. + +```cmake +prik_add_module( + physics + SOURCES interface.c + C_SOURCES implementation.c + LINK_LIBRARIES debug native_math_debug optimized native_math_release +) +``` `LINK_LIBRARIES` keeps each entry's own CMake meaning: a path to an object, archive, or shared library stays a file path, a plain name stays a library diff --git a/prik/cmake.py b/prik/cmake.py index 5410b401a..7ac3067fc 100644 --- a/prik/cmake.py +++ b/prik/cmake.py @@ -174,6 +174,19 @@ def _link_values( return tuple(libraries) +def _validate_c_suffixes(paths: Iterable[Path]) -> None: + """Reject a ``.C`` suffix, which CMake compiles as C++ rather than C. + + PRIK plans these sources as C, so letting CMake choose its own language for + them would compile the plan with the wrong compiler. + """ + for path in paths: + if path.suffix != ".c" and path.suffix.lower() == ".c": + raise ValueError( + f"PRIK C sources used through CMake must use the .c suffix; .C is interpreted as C++ by CMake: {path}" + ) + + def _module_inputs(*, paths: Iterable[str | Path], args) -> _CMakeModuleInputs: input_paths = _absolute_paths(paths) if not input_paths: @@ -309,6 +322,9 @@ def write_cmake_project( project_dir = Path(output_dir).resolve() project_dir.mkdir(parents=True, exist_ok=True) inputs = _module_inputs(paths=paths, args=args) + if language == "c" and inputs.contract is None: + _validate_c_suffixes(inputs.semantic_sources) + _validate_c_suffixes(inputs.native_c) cmake_module_dir() project_languages = ( "C Fortran" diff --git a/tests/fortran/infrastructure/building/end_to_end/test_cmake_builds.py b/tests/fortran/infrastructure/building/end_to_end/test_cmake_builds.py index c1d5d11f7..2ac8cca55 100644 --- a/tests/fortran/infrastructure/building/end_to_end/test_cmake_builds.py +++ b/tests/fortran/infrastructure/building/end_to_end/test_cmake_builds.py @@ -970,6 +970,158 @@ def test_use_prik_cmake_builds_c_source_with_include_directory_and_flag(tmp_path assert module.c_add(np.float64(2.0), np.float64(3.0)) == np.float64(6.0) +@pytest.mark.fortran_end_to_end +@pytest.mark.skipif(shutil.which("cmake") is None or shutil.which("gcc") is None, reason="CMake and gcc are required") +@pytest.mark.parametrize( + ("build_type", "expected", "rejected"), + [("Debug", "PRIK_DEBUG_DEFINE", "PRIK_RELEASE_DEFINE"), ("Release", "PRIK_RELEASE_DEFINE", "PRIK_DEBUG_DEFINE")], +) +def test_use_prik_cmake_keeps_configuration_specific_link_usage_requirements( + tmp_path: Path, build_type: str, expected: str, rejected: str +): + project = tmp_path / f"configuration usage {build_type}" + project.mkdir() + (project / "interface.c").write_text("double configured_add(double value);\n", encoding="utf-8") + (project / "implementation.c").write_text( + f"#ifndef {expected}\n" + f"#error missing {build_type} dependency compile definition\n" + "#endif\n" + f"#ifdef {rejected}\n" + f"#error unexpected {rejected} in a {build_type} build\n" + "#endif\n" + "double configured_add(double value) { return value + 1.0; }\n", + encoding="utf-8", + ) + _write_project( + project, + """add_library(debug_dependency INTERFACE) +target_compile_definitions(debug_dependency INTERFACE PRIK_DEBUG_DEFINE) +add_library(release_dependency INTERFACE) +target_compile_definitions(release_dependency INTERFACE PRIK_RELEASE_DEFINE) +prik_add_module( + configuration_usage + SOURCES interface.c + C_SOURCES implementation.c + LINK_LIBRARIES debug debug_dependency optimized release_dependency +) +""", + languages="C", + ) + build = project / "build" + command = ["cmake", "-S", str(project), "-B", str(build), f"-DCMAKE_BUILD_TYPE={build_type}"] + if shutil.which("ninja"): + command.extend(("-G", "Ninja")) + command.append(f"-DCMAKE_C_COMPILER={shutil.which('gcc')}") + _run(command) + _run(["cmake", "--build", str(build), "-j2"]) + + module = _import_extension("configuration_usage", build) + assert module.configured_add(np.float64(2.0)) == np.float64(3.0) + + +@pytest.mark.fortran_end_to_end +@pytest.mark.skipif(shutil.which("cmake") is None or shutil.which("gcc") is None, reason="CMake and gcc are required") +def test_use_prik_cmake_rejects_an_uppercase_c_suffix(tmp_path: Path): + project = tmp_path / "uppercase c suffix" + project.mkdir() + (project / "interface.C").write_text("double uppercase_add(double value);\n", encoding="utf-8") + _write_project( + project, + """prik_add_module( + uppercase_suffix + C_SOURCES interface.C +) +""", + languages="C", + ) + result = subprocess.run( + [ + "cmake", + "-S", + str(project), + "-B", + str(project / "build"), + f"-DCMAKE_C_COMPILER={shutil.which('gcc')}", + ], + env=_environment(), + capture_output=True, + text=True, + ) + + assert result.returncode != 0 + assert "must use the .c suffix" in result.stderr + + +@pytest.mark.fortran_end_to_end +def test_generate_cmake_rejects_an_uppercase_c_suffix(tmp_path: Path): + source = tmp_path / "api.C" + source.write_text("double uppercase_add(double value) { return value + 1.0; }\n", encoding="utf-8") + + result = subprocess.run( + [ + sys.executable, + "-m", + "prik", + "generate", + "--cmake", + "--language", + "c", + str(source), + "--module-name", + "uppercase_api", + "--out-dir", + str(tmp_path / "project"), + ], + env=_environment(), + capture_output=True, + text=True, + ) + + assert result.returncode != 0 + assert "must use the .c suffix" in result.stderr + + +@pytest.mark.fortran_end_to_end +@pytest.mark.skipif(shutil.which("cmake") is None or shutil.which("gcc") is None, reason="CMake and gcc are required") +def test_use_prik_cmake_requires_fortran_for_native_fortran_sources(tmp_path: Path): + project = tmp_path / "c only project with fortran sources" + project.mkdir() + (project / "interface.c").write_text("double native_add(double value);\n", encoding="utf-8") + (project / "implementation.f90").write_text( + "real(8) function native_add(value) result(output)\n" + " real(8), intent(in) :: value\n" + " output = value + 1.0d0\n" + "end function native_add\n", + encoding="utf-8", + ) + _write_project( + project, + """prik_add_module( + native_fortran_language + SOURCES interface.c + FORTRAN_SOURCES implementation.f90 +) +""", + languages="C", + ) + result = subprocess.run( + [ + "cmake", + "-S", + str(project), + "-B", + str(project / "build"), + f"-DCMAKE_C_COMPILER={shutil.which('gcc')}", + ], + env=_environment(), + capture_output=True, + text=True, + ) + + assert result.returncode != 0 + assert "native Fortran sources" in result.stderr + + @pytest.mark.fortran_end_to_end @pytest.mark.skipif(shutil.which("cmake") is None or shutil.which("gcc") is None, reason="CMake and gcc are required") def test_use_prik_cmake_propagates_dependency_usage_to_native_objects(tmp_path: Path): From 3707f5236412e756bf30c09f3d5f17b5c42a2d00 Mon Sep 17 00:00:00 2001 From: said Date: Fri, 11 Sep 2026 00:30:36 +0100 Subject: [PATCH 08/15] Plan the CMake source graph structurally instead of semantically CMake configuration ran PRIK's whole semantic pipeline -- preprocessing, parsing, semantic IR, policy completion, wrapper planning, C and Fortran lowering, and a compiled Fortran type probe -- purely to discover which files it would later generate, then ran all of it again at build time to actually write them. Configuration now asks a structural question instead, and the pipeline runs exactly once, during the build. The structural query derives everything CMake needs from the module name and its declared shape: the generated filenames, the link driver, and the mandatory ABI flags its compiler profile states. Vendor detection is a lookup on the driver's name, so no compiler runs. Querying a 300-procedure module's plan drops from 2.54s to 0.41s, and the query no longer grows with source size. Configuration cannot read a source, so the generated file list must not depend on what analysis finds. PRIK now names the optional compilation units up front and always writes them: a module needing no collision adapter still gets _adapters.c, and a Fortran module needing no bridge still gets bind_c__wrapper.f90. An unused unit holds a placeholder that defines no symbol and compiles clean under -Wall -Wextra -pedantic -Werror. A semantic edit therefore changes a file's contents, not the build graph, and never needs a reconfigure. Configure-time and build-time calls share one function, so the files CMake declares are exactly the files generation fills. Transitive semantic inputs -- nested C headers, Fortran INCLUDE files, imported contracts -- now reach CMake through a depfile written during generation, replacing the CMAKE_CONFIGURE_DEPENDS wiring that existed because a bridge source could appear or disappear. That uses add_custom_command DEPFILE, so the packaged helper requires CMake 3.21; verified working under both Ninja and Unix Makefiles. UsePRIK.cmake drops from 560 to 482 lines: gone are the plan-tree path rebasing, the per-compilation-unit JSON application, the semantic dependency wiring, and every semantic JSON read. Compile flags now use compile-language generator expressions, so no source properties remain and cross-target flag leakage is structurally impossible. The internal heavy --plan mode had no other consumer and is removed. Changed: prik/naming/generated_files.py (new, sole owner of generated filenames, replacing inline spellings in planning, codegen, and build integration), prik/cmake.py (StructuralLayout), prik/cli.py (--cmake-plan, --declared-layout, --depfile), prik/pipeline/build.py (placeholder units, depfile writer, --plan removal), cmake/UsePRIK.cmake, CMake guide, CHANGELOG. Tests: configure succeeds for a source PRIK cannot parse and fails only at build time; the bridge and adapter filenames stay fixed while their contents round-trip between placeholder and real without a reconfigure; placeholders compile under strict conformance flags; an unchanged rebuild reruns nothing; and --lto reaches both target kinds' compile lines and the link. The existing nested-header and nested-INCLUDE tests now exercise the depfile. The obsolete "reconfigures when a bridge appears" test is replaced by its inverse. Verified: full CI-shaped suite 3990 passed, CMake suite 42 passed, static gate clean. The installed-wheel test cannot run on this machine and is unchanged; packaging picks up the new module through packages.find. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01NrtPVjqMKNLJ1cYsteWiKG --- CHANGELOG.md | 13 + cmake/UsePRIK.cmake | 243 +++++---------- docs/user/guide/cmake.md | 31 +- prik/cli.py | 78 ++++- prik/cmake.py | 129 +++++++- prik/codegen/c/binding.py | 5 +- prik/compiler/native_support.py | 3 +- prik/naming/__init__.py | 18 ++ prik/naming/generated_files.py | 61 ++++ prik/pipeline/build.py | 197 ++++++++---- prik/pipeline/wrapper.py | 3 +- prik/planning/planner.py | 3 +- .../building/end_to_end/test_cmake_builds.py | 283 +++++++++++++++++- 13 files changed, 819 insertions(+), 248 deletions(-) create mode 100644 prik/naming/generated_files.py diff --git a/CHANGELOG.md b/CHANGELOG.md index dcbf49a6b..9e782daf7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,19 @@ release tags add a leading `v` to the package version. requirements, and standalone `--cmake --lto` initializes IPO for native and generated targets. +- CMake configuration no longer runs PRIK's semantic pipeline. It asks for a + structural plan -- deterministic generated filenames, the link driver, and + compiler-profile ABI flags -- and the full pipeline then runs once at build + time. Configuring a 300-procedure module's plan drops from about 2.5s to + 0.4s, and the structural query no longer grows with source size. + +- Generated CMake targets have a fixed source list. The optional collision + adapter and Fortran bridge units are always written, holding a + symbol-free placeholder when unused, so a semantic edit changes file + contents instead of the build graph and never forces a CMake reconfigure. + Transitive semantic inputs now reach CMake through a generated dependency + file, which raises the CMake floor for the packaged helper to 3.21. + - `prik generate --cmake` keeps each native link input in its own CMake category: prebuilt objects, archives, and shared libraries stay filesystem paths in `LINK_LIBRARIES` instead of becoming ambiguous relative tokens, diff --git a/cmake/UsePRIK.cmake b/cmake/UsePRIK.cmake index 56620f192..4e083a2e9 100644 --- a/cmake/UsePRIK.cmake +++ b/cmake/UsePRIK.cmake @@ -11,6 +11,15 @@ whose native compilation and linking are owned by CMake. include_guard(GLOBAL) include(CMakeParseArguments) +if(CMAKE_VERSION VERSION_LESS 3.21) + # PRIK tracks transitive semantic inputs with add_custom_command(DEPFILE), + # which only every supported generator accepts from 3.21 onward. + message( + FATAL_ERROR + "PRIK's CMake integration requires CMake 3.21 or newer for generated dependency files." + ) +endif() + if(NOT Python_EXECUTABLE OR NOT COMMAND Python_add_library) find_package(Python COMPONENTS Interpreter Development.Module REQUIRED) endif() @@ -75,105 +84,38 @@ function(_prik_json_string_list output_variable json) set(${output_variable} "${_prik_items}" PARENT_SCOPE) endfunction() -function(_prik_rebase_plan_path output_variable path source_root output_root) - file(RELATIVE_PATH _prik_relative_path "${source_root}" "${path}") - if(_prik_relative_path STREQUAL "") - set(_prik_rebased_path "${output_root}") - elseif(NOT _prik_relative_path MATCHES "^\\.\\.") - set(_prik_rebased_path "${output_root}/${_prik_relative_path}") - else() - set(_prik_rebased_path "${path}") - endif() - set(${output_variable} "${_prik_rebased_path}" PARENT_SCOPE) -endfunction() - -function(_prik_rebase_generated_paths output_variable paths source_root output_root) - set(_prik_rebased_paths) - foreach(_prik_path IN LISTS ${paths}) - file(RELATIVE_PATH _prik_relative_path "${source_root}" "${_prik_path}") - if(_prik_relative_path MATCHES "^\\.\\.") - message(FATAL_ERROR "PRIK generated output is outside its output directory: ${_prik_path}") - endif() - list(APPEND _prik_rebased_paths "${output_root}/${_prik_relative_path}") +function(_prik_add_language_options target language flags) + # Compile-language generator expressions keep a flag group on the sources + # it names without ever touching another PRIK target's sources. + foreach(_prik_flag IN LISTS flags) + target_compile_options( + "${target}" PRIVATE "$<$:${_prik_flag}>" + ) endforeach() - set(${output_variable} "${_prik_rebased_paths}" PARENT_SCOPE) endfunction() -function(_prik_apply_compilation_unit target json group index generated source_root output_root) - if(group STREQUAL "native") - set(_prik_unit_path native_build_plan compilation_units) - else() - set(_prik_unit_path generated_compilation_units) - endif() - string(JSON _prik_unit_source GET "${json}" ${_prik_unit_path} ${index} source) - string(JSON _prik_unit_language GET "${json}" ${_prik_unit_path} ${index} language) - if(generated) - _prik_rebase_plan_path( - _prik_unit_source "${_prik_unit_source}" "${source_root}" "${output_root}" - ) - endif() - _prik_json_string_list(_prik_unit_flags "${json}" ${_prik_unit_path} ${index} flags) - _prik_json_string_list(_prik_unit_abi_flags "${json}" ${_prik_unit_path} ${index} abi_flags) - _prik_json_string_list(_prik_unit_include_dirs "${json}" ${_prik_unit_path} ${index} include_dirs) - - set(_prik_rebased_include_dirs) - foreach(_prik_include_dir IN LISTS _prik_unit_include_dirs) - _prik_rebase_plan_path( - _prik_rebased_include_dir "${_prik_include_dir}" "${source_root}" "${output_root}" - ) - list(APPEND _prik_rebased_include_dirs "${_prik_rebased_include_dir}") - endforeach() - if(_prik_unit_language STREQUAL "fortran") - set(_prik_cmake_unit_language Fortran) - else() - set(_prik_cmake_unit_language C) - endif() - if(_prik_unit_flags) - if(generated) - set_property( - SOURCE "${_prik_unit_source}" - APPEND PROPERTY COMPILE_OPTIONS ${_prik_unit_flags} - ) - else() - foreach(_prik_unit_flag IN LISTS _prik_unit_flags) - target_compile_options( - "${target}" PRIVATE - "$<$:${_prik_unit_flag}>" - ) - endforeach() - endif() - endif() - foreach(_prik_abi_flag IN LISTS _prik_unit_abi_flags) - set(_prik_abi_key "${_prik_cmake_unit_language}:${_prik_abi_flag}") - get_property(_prik_applied_abi_flags TARGET "${target}" PROPERTY _PRIK_APPLIED_ABI_FLAGS) - if(NOT _prik_abi_key IN_LIST _prik_applied_abi_flags) - target_compile_options( - "${target}" PRIVATE "$<$:${_prik_abi_flag}>" - ) - set_property(TARGET "${target}" APPEND PROPERTY _PRIK_APPLIED_ABI_FLAGS "${_prik_abi_key}") - endif() - endforeach() - if(_prik_rebased_include_dirs) - if(generated) - set_property( - SOURCE "${_prik_unit_source}" - APPEND PROPERTY INCLUDE_DIRECTORIES ${_prik_rebased_include_dirs} - ) - else() - target_include_directories("${target}" PRIVATE ${_prik_rebased_include_dirs}) - endif() +function(_prik_json_abi_flags output_variable json language) + string(JSON _prik_abi_type ERROR_VARIABLE _prik_abi_error TYPE "${json}" required_abi_flags ${language}) + if(_prik_abi_error OR NOT _prik_abi_type STREQUAL "ARRAY") + set(${output_variable} "" PARENT_SCOPE) + return() endif() + _prik_json_string_list(_prik_abi_flags "${json}" required_abi_flags ${language}) + set(${output_variable} "${_prik_abi_flags}" PARENT_SCOPE) endfunction() + function(_prik_validate_args args) foreach(_prik_arg IN LISTS ${args}) - if(_prik_arg MATCHES "^--(analysis-fortran-compiler|build-manifest|cmake|compiler|json|jobs|language|lto|makefile|module-name|native-c-compile-flags|native-c-sources|native-compile-flags|native-fortran-sources|native-library|native-library-dir|native-link-item|native-linker-language|native-objects|no-compile-input-sources|no-standard-logicals|out|out-dir|plan|pyi|sources|wrapper-c-flags|wrapper-compiler-debug|wrapper-fortran-flags)(=|$)") + if(_prik_arg MATCHES "^--(analysis-fortran-compiler|build-manifest|cmake|cmake-plan|compiler|declared-layout|depfile|json|jobs|language|lto|makefile|module-name|native-c-compile-flags|native-c-sources|native-compile-flags|native-fortran-sources|native-library|native-library-dir|native-link-item|native-linker-language|native-objects|no-compile-input-sources|no-standard-logicals|out|out-dir|plan|pyi|sources|wrapper-c-flags|wrapper-compiler-debug|wrapper-fortran-flags)(=|$)") message(FATAL_ERROR "PRIK_ARGS cannot override prik_add_module build ownership: ${_prik_arg}") endif() endforeach() endfunction() function(prik_add_module name) + # 1. Parse and validate the public arguments, then settle the module's + # structural languages before anything is created. if(NOT name MATCHES "^[A-Za-z_][A-Za-z0-9_]*$") message(FATAL_ERROR "PRIK module name must be a Python/CMake identifier: ${name}") endif() @@ -386,12 +328,13 @@ function(prik_add_module name) list(APPEND _prik_generate_command ${PRIK_PRIK_ARGS}) list(APPEND _prik_generate_command --json) - # Ask PRIK's completed plan for canonical output names without materializing - # sources. The actual source generation remains the custom command below. - set(_prik_plan_dir "${CMAKE_CURRENT_BINARY_DIR}/CMakeFiles/prik-plan/${name}") - file(REMOVE_RECURSE "${_prik_plan_dir}") - set(_prik_plan_command ${_prik_generate_command} --plan --out-dir "${_prik_plan_dir}") - + # 2. Ask PRIK for the deterministic generated-file graph. This is a + # structural query: it derives filenames, the link driver, and mandatory + # ABI flags from the module's declared shape, and never parses a source, + # completes policy, or generates code. The real pipeline runs once, in + # the build-time custom command below. + list(APPEND _prik_generate_command --declared-layout) + set(_prik_plan_command ${_prik_generate_command} --cmake-plan --out-dir "${_prik_output_dir}") execute_process( COMMAND ${_prik_plan_command} WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}" @@ -400,54 +343,27 @@ function(prik_add_module name) ERROR_VARIABLE _prik_configure_error ) if(NOT _prik_configure_result EQUAL 0) - message(FATAL_ERROR "PRIK wrapper generation failed for ${name}:\n${_prik_configure_error}") + message(FATAL_ERROR "PRIK structural planning failed for ${name}:\n${_prik_configure_error}") endif() - _prik_json_string_list(_prik_planned_sources "${_prik_plan_json}" generated_sources) - if(NOT _prik_planned_sources) + _prik_json_string_list(_prik_generated_sources "${_prik_plan_json}" generated_sources) + if(NOT _prik_generated_sources) message(FATAL_ERROR "PRIK did not return generated wrapper sources for ${name}") endif() - _prik_json_string_list(_prik_planned_outputs "${_prik_plan_json}" generated_files) - if(NOT _prik_planned_outputs) + _prik_json_string_list(_prik_generation_outputs "${_prik_plan_json}" generated_files) + if(NOT _prik_generation_outputs) message(FATAL_ERROR "PRIK did not return generated outputs for ${name}") endif() - _prik_json_string_list(_prik_semantic_dependencies "${_prik_plan_json}" semantic_dependencies) - _prik_json_string_list(_prik_extension_link_flags "${_prik_plan_json}" extension_link_flags) + string(JSON _prik_required_linker_language GET "${_prik_plan_json}" linker_language) + string(JSON _prik_depfile GET "${_prik_plan_json}" depfile) + _prik_json_abi_flags(_prik_fortran_abi_flags "${_prik_plan_json}" fortran) + list(APPEND _prik_generate_command --depfile "${_prik_depfile}") - string(JSON _prik_linker_language_type TYPE "${_prik_plan_json}" linker_language) - if(_prik_linker_language_type STREQUAL "STRING") - string(JSON _prik_required_linker_language GET "${_prik_plan_json}" linker_language) - elseif(NOT _prik_linker_language_type STREQUAL "NULL") - message(FATAL_ERROR "PRIK returned an invalid linker_language for ${name}") - endif() - - string( - JSON _prik_generated_unit_count - ERROR_VARIABLE _prik_generated_plan_error - LENGTH "${_prik_plan_json}" generated_compilation_units - ) - if(_prik_generated_plan_error) - message(FATAL_ERROR "PRIK did not return generated compilation units: ${_prik_generated_plan_error}") - endif() - - string( - JSON _prik_native_unit_count - ERROR_VARIABLE _prik_native_plan_error - LENGTH "${_prik_plan_json}" native_build_plan compilation_units - ) - if(_prik_native_plan_error) - message(FATAL_ERROR "PRIK did not return native compilation units: ${_prik_native_plan_error}") - endif() - set(_prik_native_target_sources) - if(_prik_native_unit_count GREATER 0) - math(EXPR _prik_last_native_unit_index "${_prik_native_unit_count} - 1") - foreach(_prik_native_unit_index RANGE 0 ${_prik_last_native_unit_index}) - string( - JSON _prik_native_target_source - GET "${_prik_plan_json}" native_build_plan compilation_units ${_prik_native_unit_index} source - ) - list(APPEND _prik_native_target_sources "${_prik_native_target_source}") - endforeach() + # 3. Compile the caller's native implementation in its own object target so + # its flags and usage requirements stay separate from generated code. + set(_prik_native_target_sources ${_prik_native_fortran_sources} ${_prik_native_c_sources}) + if(NOT _prik_contract AND NOT PRIK_NO_COMPILE_INPUT_SOURCES) + list(APPEND _prik_native_target_sources ${_prik_wrapper_sources}) endif() set(_prik_native_target) @@ -465,6 +381,13 @@ function(prik_add_module name) # target-only filter would flatten or drop. target_link_libraries("${_prik_native_target}" PRIVATE ${PRIK_LINK_LIBRARIES}) endif() + target_include_directories( + "${_prik_native_target}" + PRIVATE "${_prik_output_dir}" ${_prik_include_dirs} ${_prik_module_dirs} + ) + _prik_add_language_options("${_prik_native_target}" C "${PRIK_C_FLAGS}") + _prik_add_language_options("${_prik_native_target}" Fortran "${PRIK_FORTRAN_FLAGS}") + _prik_add_language_options("${_prik_native_target}" Fortran "${_prik_fortran_abi_flags}") if(_prik_required_linker_language STREQUAL "fortran") set_target_properties( "${_prik_native_target}" PROPERTIES Fortran_MODULE_DIRECTORY "${_prik_output_dir}" @@ -482,40 +405,40 @@ function(prik_add_module name) if(NOT _prik_numpy_result EQUAL 0) message(FATAL_ERROR "PRIK requires NumPy headers for ${name}:\n${_prik_numpy_error}") endif() - _prik_rebase_generated_paths( - _prik_generated_sources _prik_planned_sources "${_prik_plan_dir}" "${_prik_output_dir}" - ) - _prik_rebase_generated_paths( - _prik_generation_outputs _prik_planned_outputs "${_prik_plan_dir}" "${_prik_output_dir}" - ) - - # Semantic inputs can add or remove a bridge source, so make CMake - # reconfigure before it evaluates the target source list again. - set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS ${_prik_semantic_dependencies}) + # 4. Declare the one generation step. Its outputs are fixed, so a semantic + # edit changes file contents rather than the build graph and never needs + # a reconfigure. Transitive semantic inputs arrive through the depfile + # PRIK writes during that same run. set(_prik_command_signature_dir "${CMAKE_CURRENT_BINARY_DIR}/CMakeFiles/prik") file(MAKE_DIRECTORY "${_prik_command_signature_dir}") set(_prik_command_signature "${_prik_command_signature_dir}/${name}-generation-command.txt") string(JOIN "\n" _prik_command_signature_text ${_prik_generate_command} --out-dir "${_prik_output_dir}") file(CONFIGURE OUTPUT "${_prik_command_signature}" CONTENT "${_prik_command_signature_text}\n" @ONLY) + if(_prik_contract) + set(_prik_declared_inputs "${_prik_contract}") + else() + set(_prik_declared_inputs ${_prik_wrapper_sources}) + endif() set(_prik_dependencies - ${_prik_semantic_dependencies} + ${_prik_declared_inputs} "${_prik_command_signature}" "${CMAKE_CURRENT_FUNCTION_LIST_FILE}" ) list(FILTER _prik_dependencies EXCLUDE REGEX "^$") set(_prik_actual_generate_command ${_prik_generate_command} --out-dir "${_prik_output_dir}") - file(REMOVE_RECURSE "${_prik_plan_dir}") add_custom_command( OUTPUT ${_prik_generation_outputs} COMMAND ${_prik_actual_generate_command} DEPENDS ${_prik_dependencies} + DEPFILE "${_prik_depfile}" WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}" VERBATIM COMMENT "Generate PRIK wrapper sources for ${name}" ) + # 5. Create the Python extension target over the fixed generated sources. Python_add_library("${name}" MODULE WITH_SOABI ${_prik_generated_sources}) set(_prik_additional_outputs ${_prik_generation_outputs}) list(REMOVE_ITEM _prik_additional_outputs ${_prik_generated_sources}) @@ -526,24 +449,14 @@ function(prik_add_module name) target_sources("${name}" PRIVATE "$") add_dependencies("${name}" "${_prik_native_target}") endif() - if(_prik_generated_unit_count GREATER 0) - math(EXPR _prik_last_generated_unit_index "${_prik_generated_unit_count} - 1") - foreach(_prik_generated_unit_index RANGE 0 ${_prik_last_generated_unit_index}) - _prik_apply_compilation_unit( - "${name}" "${_prik_plan_json}" generated ${_prik_generated_unit_index} TRUE - "${_prik_plan_dir}" "${_prik_output_dir}" - ) - endforeach() - endif() - if(_prik_native_unit_count GREATER 0) - math(EXPR _prik_last_native_unit_index "${_prik_native_unit_count} - 1") - foreach(_prik_native_unit_index RANGE 0 ${_prik_last_native_unit_index}) - _prik_apply_compilation_unit( - "${_prik_native_target}" "${_prik_plan_json}" native ${_prik_native_unit_index} FALSE - "${_prik_plan_dir}" "${_prik_output_dir}" - ) - endforeach() - endif() + + # 6. Generated sources carry only the generated-code flag groups, expressed + # per compile language so they never leak onto native sources. + _prik_add_language_options("${name}" C "${PRIK_WRAPPER_C_FLAGS}") + _prik_add_language_options("${name}" Fortran "${PRIK_WRAPPER_FORTRAN_FLAGS}") + _prik_add_language_options("${name}" Fortran "${_prik_fortran_abi_flags}") + + # 7. Link requirements: libraries, driver language, search and runtime paths. set_target_properties("${name}" PROPERTIES PREFIX "" OUTPUT_NAME "${name}") if(_prik_required_linker_language STREQUAL "fortran") set_property(TARGET "${name}" PROPERTY LINKER_LANGUAGE Fortran) @@ -572,7 +485,9 @@ function(prik_add_module name) target_link_directories("${name}" PRIVATE ${_prik_library_dirs}) set_property(TARGET "${name}" APPEND PROPERTY BUILD_RPATH ${_prik_library_dirs}) endif() - if(_prik_extension_link_flags OR PRIK_LINK_OPTIONS) - target_link_options("${name}" PRIVATE ${_prik_extension_link_flags} ${PRIK_LINK_OPTIONS}) + if(PRIK_WRAPPER_C_FLAGS OR PRIK_LINK_OPTIONS) + # WRAPPER_C_FLAGS reaches the extension link as well as generated C + # compilation, matching PRIK's own build behavior. + target_link_options("${name}" PRIVATE ${PRIK_WRAPPER_C_FLAGS} ${PRIK_LINK_OPTIONS}) endif() endfunction() diff --git a/docs/user/guide/cmake.md b/docs/user/guide/cmake.md index b77256596..a09fb2473 100644 --- a/docs/user/guide/cmake.md +++ b/docs/user/guide/cmake.md @@ -17,13 +17,37 @@ default, PRIK uses CMake's selected C or Fortran compiler for preprocessing, source analysis, and ABI probes. CMake mode does not accept a separate `--compiler` override because the analyzed and compiled toolchains must agree. +The work splits across the two CMake phases: + +- **Configure** asks PRIK only for structure: the generated filenames, the link + driver, and any compiler ABI flags its profile requires. PRIK does not + preprocess, parse, complete policy, plan, or generate code here, so + configuring stays cheap however large the sources are. +- **Build** runs the full PRIK pipeline exactly once, writing the wrapper, the + header, the binding-support headers, and a dependency file. + +Because configuration never reads a source, the generated file list cannot +depend on what analysis finds. PRIK therefore names the optional compilation +units up front and always writes them: a module that needs no collision-adapter +unit still gets `_adapters.c`, and a Fortran module that needs no bridge +still gets `bind_c__wrapper.f90`. An unused unit holds a small +placeholder that defines no symbol and compiles without warnings. A semantic +edit then changes a file's *contents* rather than the set of files, so editing +a source never requires re-running `cmake` to configure. + +Transitive semantic inputs -- a nested C header, a Fortran `INCLUDE` file, an +imported contract -- reach CMake through the dependency file PRIK writes during +generation, so changing one reruns generation on the next build. This uses +`add_custom_command(DEPFILE)`, which is why the helper requires CMake 3.21 or +newer. + ## Existing CMake project Install PRIK, make its `cmake` directory available through `CMAKE_MODULE_PATH`, and include the packaged helper: ```cmake -cmake_minimum_required(VERSION 3.20) +cmake_minimum_required(VERSION 3.21) project(MyPhysics LANGUAGES C Fortran) @@ -130,8 +154,9 @@ prik_add_module( The generated wrapper sources are custom-command outputs. Changing a semantic source, contract, included C header, or Fortran `INCLUDE` file regenerates them -before CMake compiles the target. CMake recompiles contract-first native -implementations independently. +before CMake compiles the target, without a configure step. CMake recompiles +contract-first native implementations independently. A build with no changes +reruns neither generation nor compilation. External dependencies remain CMake dependencies. For example, CMake can find BLAS and pass its target to the PRIK extension: diff --git a/prik/cli.py b/prik/cli.py index a9ca96ef2..d71761baa 100644 --- a/prik/cli.py +++ b/prik/cli.py @@ -1084,14 +1084,14 @@ def _validate_wrapper_build_options(args: argparse.Namespace, parser: argparse.A parser.error("generate --cmake uses CMake's selected compiler; do not pass --compiler") if getattr(args, "wrapper_compiler_debug", False): parser.error("generate --cmake does not accept --wrapper-compiler-debug; use CMake build types") - if getattr(args, "plan_only", False) and not (args.command == "generate" and args.generate_sources): - parser.error("--plan requires generate --sources") + if getattr(args, "cmake_plan", False) and not (args.command == "generate" and args.generate_sources): + parser.error("--cmake-plan requires generate --sources") if args.command == "build": _validate_wrapper_out(args, parser) if _wrapper_build_uses_manifest(args): - if getattr(args, "cmake", False) or getattr(args, "plan_only", False): - parser.error("generate --cmake/--plan requires source or contract inputs, not --build-manifest") + if getattr(args, "cmake", False) or getattr(args, "cmake_plan", False): + parser.error("generate --cmake requires source or contract inputs, not --build-manifest") _validate_manifest_wrapper_options(args, parser) return @@ -1360,6 +1360,44 @@ def _copy_wrapper_shared_library_alias(args: argparse.Namespace, result): return replace(result, shared_library=target, generated_files=generated_files) +def _cli_structural_layout(args: argparse.Namespace): + """Return the deterministic generated-file graph for a CMake-declared build. + + The configure-time query and the real generation step call this same + function, so the files CMake declares are exactly the files generation + fills in. + """ + from prik.cmake import structural_layout + + return structural_layout( + module_name=_wrapper_output_name(args) or Path(args.paths[0]).stem, + output_dir=getattr(args, "out_dir", None) or "__prik__", + language=args.language, + native_fortran_sources=getattr(args, "native_fortran_sources", None) or (), + linker_language=getattr(args, "native_linker_language", None), + fortran_compiler=_analysis_fortran_compiler(args), + standard_logicals=getattr(args, "standard_logicals", True), + ) + + +def _analysis_fortran_compiler(args: argparse.Namespace) -> str | None: + """Return the Fortran driver whose profile states PRIK's mandatory ABI flags.""" + explicit = getattr(args, "analysis_fortran_compiler", None) + if explicit: + return str(explicit) + if args.language == "fortran": + compiler = getattr(args, "compiler", None) + return str(compiler) if compiler else None + return None + + +def _cli_declared_generated_sources(args: argparse.Namespace): + """Return the generated sources a build system already declared, if any.""" + if not getattr(args, "declared_layout", False): + return None + return _cli_structural_layout(args).generated_sources + + def _cli_native_libraries(raw_libraries: list[str] | None) -> tuple[str, ...]: if not raw_libraries: return () @@ -1432,6 +1470,12 @@ def _run_stage_reports_with_diagnostics(args: argparse.Namespace, preprocessing: def _run_wrap_build(args: argparse.Namespace, preprocessing: PreprocessingConfig): + if getattr(args, "cmake_plan", False): + # Answer the build system's configure-time question from structure + # alone. Nothing below this point runs: no preprocessing, no parsing, + # no policy, no planning, and no code generation. + return _cli_structural_layout(args) + if getattr(args, "cmake", False): from prik.cmake import write_cmake_project @@ -1504,7 +1548,8 @@ def record_total_build_time(elapsed: float) -> None: positional_only=getattr(args, "positional_only", False), makefile=getattr(args, "makefile", False), generate_sources=getattr(args, "generate_sources", False), - _plan_only=getattr(args, "plan_only", False), + _declared_generated_sources=_cli_declared_generated_sources(args), + _depfile=getattr(args, "depfile", None), _external_native_implementation=getattr(args, "external_native_implementation", False), jobs=getattr(args, "jobs", None), standard_logicals=getattr(args, "standard_logicals", True), @@ -1550,7 +1595,8 @@ def record_total_build_time(elapsed: float) -> None: positional_only=getattr(args, "positional_only", False), makefile=getattr(args, "makefile", False), generate_sources=getattr(args, "generate_sources", False), - _plan_only=getattr(args, "plan_only", False), + _declared_generated_sources=_cli_declared_generated_sources(args), + _depfile=getattr(args, "depfile", None), _external_native_implementation=getattr(args, "external_native_implementation", False), jobs=getattr(args, "jobs", None), verbose=1 if getattr(args, "verbose", False) else 0, @@ -1594,7 +1640,8 @@ def record_total_build_time(elapsed: float) -> None: native_linker_language=getattr(args, "native_linker_language", None), makefile=getattr(args, "makefile", False), generate_sources=getattr(args, "generate_sources", False), - _plan_only=getattr(args, "plan_only", False), + _declared_generated_sources=_cli_declared_generated_sources(args), + _depfile=getattr(args, "depfile", None), _external_native_implementation=getattr(args, "external_native_implementation", False), jobs=getattr(args, "jobs", None), verbose=1 if getattr(args, "verbose", False) else 0, @@ -1943,6 +1990,12 @@ def _print_wrap_build_output(args: argparse.Namespace, result) -> None: _print_verbose_total_build_time(args) return + if payload.get("structural_plan"): + print(f"Structural plan for {payload['module_name']}:") + for source in payload["generated_sources"]: + print(f" {source}") + return + if payload.get("cmake_project"): print(f"Generated CMake project: {payload['cmake_project']}") return @@ -2425,7 +2478,9 @@ def _add_diagnostic_controls(group: argparse._ArgumentGroup, *, allow_verbose: b "generate_sources": False, "makefile": False, "cmake": False, - "plan_only": False, + "cmake_plan": False, + "declared_layout": False, + "depfile": None, "show_vars": False, "print_limit": None, "vars_limit": None, @@ -2844,7 +2899,12 @@ def _generate_parser(argv: list[str]) -> argparse.ArgumentParser: metavar="NAME", help="Python module name for generated wrapper sources", ) - output_group.add_argument("--plan", dest="plan_only", action="store_true", help=argparse.SUPPRESS) + # Internal build-integration options. A build system queries the + # structural layout at configure time, then declares it back during + # generation so the two agree by construction. + output_group.add_argument("--cmake-plan", dest="cmake_plan", action="store_true", help=argparse.SUPPRESS) + output_group.add_argument("--declared-layout", dest="declared_layout", action="store_true", help=argparse.SUPPRESS) + output_group.add_argument("--depfile", dest="depfile", metavar="PATH", help=argparse.SUPPRESS) output_group.add_argument( "--external-native-implementation", dest="external_native_implementation", diff --git a/prik/cmake.py b/prik/cmake.py index 7ac3067fc..dd767c079 100644 --- a/prik/cmake.py +++ b/prik/cmake.py @@ -2,7 +2,7 @@ from __future__ import annotations -from collections.abc import Iterable +from collections.abc import Iterable, Mapping from dataclasses import dataclass import os from pathlib import Path @@ -10,6 +10,13 @@ import sys import sysconfig +from prik.naming.generated_files import ( + adapter_source_name, + binding_source_name, + bridge_source_name, + wrapper_header_name, +) + @dataclass(frozen=True) class CMakeProjectResult: @@ -28,6 +35,124 @@ def to_dict(self) -> dict[str, object]: } +@dataclass(frozen=True) +class StructuralLayout: + """The generated-file graph one CMake module target declares. + + Every field follows from the module name and the declared build structure, + so a build system can create its targets before any source is parsed. The + semantic pipeline never runs to produce this; it only fills the declared + files in later. + """ + + module_name: str + output_dir: Path + fortran: bool + linker_language: str + abi_flags: Mapping[str, tuple[str, ...]] + + @property + def generated_sources(self) -> tuple[Path, ...]: + """Return every generated compilation unit, in compiler order. + + The optional adapter and bridge units are listed whether or not the + wrapper turns out to need them; generation writes a harmless stub for + one it does not, which keeps this list stable across semantic edits. + """ + bridge = (self.output_dir / bridge_source_name(self.module_name),) if self.fortran else () + return ( + *bridge, + self.output_dir / binding_source_name(self.module_name), + self.output_dir / adapter_source_name(self.module_name), + ) + + @property + def generated_files(self) -> tuple[Path, ...]: + """Return the compilation units plus every other deterministic output.""" + # Imported here so a structural query does not pay for the support + # module's own NumPy dependency. + from prik.compiler.native_support import BINDING_SUPPORT_IMPORT, native_support_output_paths + + return ( + *self.generated_sources, + self.output_dir / wrapper_header_name(self.module_name), + *native_support_output_paths((BINDING_SUPPORT_IMPORT,), prik_dirpath=self.output_dir), + ) + + @property + def depfile(self) -> Path: + """Return the depfile generation writes for its semantic inputs.""" + return self.output_dir / f"prik-{self.module_name}.d" + + def to_dict(self) -> dict[str, object]: + """Return the payload a build integration reads at configure time.""" + return { + "structural_plan": True, + "module_name": self.module_name, + "generated_sources": [str(path) for path in self.generated_sources], + "generated_files": [str(path) for path in self.generated_files], + "depfile": str(self.depfile), + "linker_language": self.linker_language, + "required_abi_flags": {language: list(flags) for language, flags in self.abi_flags.items()}, + } + + +def structural_layout( + *, + module_name: str, + output_dir: str | Path, + language: str, + native_fortran_sources: Iterable[str | Path] = (), + linker_language: str | None = None, + fortran_compiler: str | None = None, + standard_logicals: bool = True, +) -> StructuralLayout: + """Derive one module's generated-file graph from build structure alone. + + A module is Fortran-capable when its own language is Fortran, when it + contributes native Fortran sources, or when the caller has already required + the Fortran link driver. That is the whole rule: nothing here reads a + source file, completes policy, or plans a wrapper. + """ + if not module_name.isascii() or not module_name.isidentifier(): + raise ValueError(f"CMake structural planning requires a valid ASCII Python/C module name: {module_name!r}") + requested_linker_language = (linker_language or "").lower() or None + if requested_linker_language not in (None, "c", "fortran"): + raise ValueError(f"Unsupported linker language: {linker_language!r}") + fortran = language == "fortran" or bool(tuple(native_fortran_sources)) or requested_linker_language == "fortran" + return StructuralLayout( + module_name=module_name, + output_dir=Path(output_dir).resolve(), + fortran=fortran, + linker_language=requested_linker_language or ("fortran" if fortran else "c"), + abi_flags=_structural_abi_flags( + fortran_compiler if fortran else None, + standard_logicals=standard_logicals, + ), + ) + + +def _structural_abi_flags(fortran_compiler: str | None, *, standard_logicals: bool) -> Mapping[str, tuple[str, ...]]: + """Return mandatory ABI flags from the compiler profile, not from semantics. + + The profile follows from the driver's name, so this stays a table lookup. + An unrecognized driver contributes no mandatory flag rather than failing + configuration; the real generation step still validates the toolchain. + """ + if fortran_compiler is None: + return {} + from prik.compiler.compiler_profiles import available_compilers, fortran_compiler_family + + try: + _token, vendor, _default_c = fortran_compiler_family(fortran_compiler) + except ValueError: + return {} + if not standard_logicals: + return {} + flags = available_compilers[vendor].get("fortran", {}).get("logical_interop_flags", ()) + return {"fortran": tuple(str(flag) for flag in flags)} if flags else {} + + @dataclass(frozen=True) class _CMakeModuleInputs: """Normalized source declarations for one generated helper call.""" @@ -223,7 +348,7 @@ def _module_inputs(*, paths: Iterable[str | Path], args) -> _CMakeModuleInputs: def _project_preamble(*, module_name: str, languages: str, lto: bool = False) -> list[str]: lines = [ - "cmake_minimum_required(VERSION 3.20)", + "cmake_minimum_required(VERSION 3.21)", "", f"project({module_name} LANGUAGES {languages})", "", diff --git a/prik/codegen/c/binding.py b/prik/codegen/c/binding.py index 028fcb9ac..e52704e4a 100644 --- a/prik/codegen/c/binding.py +++ b/prik/codegen/c/binding.py @@ -58,6 +58,7 @@ WritebackPhase, ) from prik.codegen.c.naming import CBindingNames +from prik.naming.generated_files import adapter_module_name, binding_module_name from prik.codegen.c.python_surface import PythonSurfaceContext, PythonSurfaceEmitter from prik.semantics.scalar_types import is_boolean_semantic_type_name from prik.codegen.nodes import ( @@ -337,7 +338,7 @@ def binding_module(self, plan: ModulePlan) -> CModule: needs_native_support = self.requires_native_support(plan) needs_free = self._module_needs_allocator(plan) return CModule( - name=f"{plan.binding.owner_path}_wrapper", + name=binding_module_name(plan.binding.owner_path), defines=self._module_defines(plan, needs_native_support), includes=self._module_includes(plan, needs_native_support, needs_free), declarations=self._module_declarations(plan), @@ -463,7 +464,7 @@ def _collision_adapter_module(self, plan: ModulePlan) -> CModule | None: if not adapted: return None return CModule( - name=f"{plan.binding.owner_path}_adapters", + name=adapter_module_name(plan.binding.owner_path), includes=( CInclude("stdint.h"), CInclude("stdbool.h"), diff --git a/prik/compiler/native_support.py b/prik/compiler/native_support.py index a9fe7a93e..d92049def 100644 --- a/prik/compiler/native_support.py +++ b/prik/compiler/native_support.py @@ -14,7 +14,8 @@ import prik.runtime.native_support as native_support_folder -_NATIVE_SUPPORT_IMPORT = "binding_support" +BINDING_SUPPORT_IMPORT = "binding_support" +_NATIVE_SUPPORT_IMPORT = BINDING_SUPPORT_IMPORT _NATIVE_SUPPORT_SOURCE = Path(native_support_folder.__file__).parent diff --git a/prik/naming/__init__.py b/prik/naming/__init__.py index a9182efac..6f31439ef 100644 --- a/prik/naming/__init__.py +++ b/prik/naming/__init__.py @@ -1,5 +1,15 @@ """Naming policy for public APIs and generated target-language symbols.""" +from .generated_files import ( + adapter_module_name, + adapter_source_name, + binding_module_name, + binding_source_name, + bridge_module_name, + bridge_source_name, + stub_identifier, + wrapper_header_name, +) from .policy import ( GeneratedSymbolRules, NamingPolicy, @@ -14,6 +24,14 @@ "NamingPolicy", "NormalizedPublicName", "PublicNameRecord", + "adapter_module_name", + "adapter_source_name", + "binding_module_name", + "binding_source_name", + "bridge_module_name", + "bridge_source_name", "generated_symbol_rules", "normalize_public_name", + "stub_identifier", + "wrapper_header_name", ) diff --git a/prik/naming/generated_files.py b/prik/naming/generated_files.py new file mode 100644 index 000000000..fbb39109c --- /dev/null +++ b/prik/naming/generated_files.py @@ -0,0 +1,61 @@ +"""Canonical filenames for one module's generated wrapper sources. + +Every generated compilation unit's name follows from the module name alone, so +a build integration can declare its source graph before any source is parsed. +This module is the single owner of that mapping: planning, code generation, and +build integration all read the names from here rather than repeating the +spelling. :func:`stub_identifier` supplies the bounded identifier a placeholder +unit needs when an optional unit is declared but not required. +""" + +from __future__ import annotations + +from hashlib import blake2b + +_STUB_DIGEST_SIZE = 6 + + +def binding_module_name(module_name: str) -> str: + """Return the C module name of the always-present CPython binding unit.""" + return f"{module_name}_wrapper" + + +def adapter_module_name(module_name: str) -> str: + """Return the C module name of the collision-adapter forwarder unit.""" + return f"{module_name}_adapters" + + +def bridge_module_name(module_name: str) -> str: + """Return the Fortran module name of the generated bridge unit.""" + return f"bind_c_{module_name}_wrapper" + + +def binding_source_name(module_name: str) -> str: + """Return the filename of the always-present CPython binding source.""" + return f"{binding_module_name(module_name)}.c" + + +def adapter_source_name(module_name: str) -> str: + """Return the filename of the collision-adapter compilation unit.""" + return f"{adapter_module_name(module_name)}.c" + + +def bridge_source_name(module_name: str) -> str: + """Return the filename of the generated Fortran bridge compilation unit.""" + return f"{bridge_module_name(module_name)}.f90" + + +def wrapper_header_name(module_name: str) -> str: + """Return the filename of the generated wrapper header.""" + return f"{binding_module_name(module_name)}.h" + + +def stub_identifier(prefix: str, module_name: str) -> str: + """Return a bounded identifier for a placeholder compilation unit. + + A Python module name may be long or carry characters a target language + rejects, so the identity comes from a short digest of the name rather than + the name itself. The result is stable for a given module. + """ + digest = blake2b(module_name.encode("utf-8"), digest_size=_STUB_DIGEST_SIZE).hexdigest() + return f"{prefix}_{digest}" diff --git a/prik/pipeline/build.py b/prik/pipeline/build.py index 515bda8e3..edc3ac4e5 100644 --- a/prik/pipeline/build.py +++ b/prik/pipeline/build.py @@ -34,7 +34,8 @@ from prik.compiler.objects import ObjectFile from prik.compiler.compilers import Compiler, get_condaless_search_path -from prik.compiler.native_support import install_native_support, native_support_output_paths +from prik.compiler.native_support import BINDING_SUPPORT_IMPORT, install_native_support +from prik.naming.generated_files import stub_identifier from prik.parsers.c import parse_c_file from prik.parsers.c.cli import attach_preprocessing_recipe from prik.parsers.fortran.parser import parse_fortran_project @@ -879,6 +880,99 @@ def _write_generated_wrapper_sources( return tuple(written) +def _depfile_target_text(path: Path | str) -> str: + """Escape one path for the Make-style depfile grammar CMake reads. + + A space separates entries, ``#`` starts a comment, and ``$`` introduces a + variable reference, so each has to be escaped for the path to survive as + one token. Backslashes are escaped first so the escapes added here are not + themselves re-read. + """ + text = str(Path(path).resolve()) + for character in ("\\", " ", "#"): + text = text.replace(character, f"\\{character}") + return text.replace("$", "$$") + + +def write_generation_depfile( + depfile: Path | str, + *, + outputs: Iterable[Path | str], + dependencies: Iterable[Path | str], +) -> Path: + """Write the semantic inputs one generation step consumed, for the build system. + + The build system reruns generation when any listed dependency changes, so + transitive semantic inputs -- nested C headers, Fortran ``INCLUDE`` files, + imported contracts -- no longer have to be discovered before the build + graph exists. + """ + depfile_path = Path(depfile) + depfile_path.parent.mkdir(parents=True, exist_ok=True) + target_text = " ".join(_depfile_target_text(output) for output in outputs) + lines = [f"{target_text}:"] + lines.extend(f" {_depfile_target_text(dependency)}" for dependency in dict.fromkeys(dependencies)) + depfile_path.write_text(" \\\n".join(lines) + "\n", encoding="utf-8") + return depfile_path + + +def _write_deterministic_layout_stubs( + rendered: GeneratedWrapper, + *, + declared_sources: Iterable[str | Path], + verbose: bool | int = False, +) -> tuple[Path, ...]: + """Materialize the optional compilation units this wrapper did not need. + + A build system that declares its source graph before semantic analysis has + to compile the same file list whichever way the analysis turns out. Writing + a valid placeholder for an unused adapter or bridge unit keeps that list + fixed, so a semantic edit changes a file's contents rather than the set of + files. Each stub is a complete, warning-free translation unit that defines + no symbol the rest of the build can reach. + """ + module_name = rendered.module_name + written: list[Path] = [] + produced = {Path(path).name for path in rendered.compile_sources} + for declared in declared_sources: + path = Path(declared) + if path.name in produced: + continue + text = _c_stub_source(module_name) if path.suffix == ".c" else _fortran_stub_source(module_name) + _print_verbose_step(verbose, f"Write unused generated unit placeholder: {path}") + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + written.append(path) + return tuple(written) + + +def _c_stub_source(module_name: str) -> str: + """Return a placeholder C unit that is valid under strict conformance flags. + + A declaration alone keeps the translation unit non-empty, which ISO C + requires, while defining nothing and exporting nothing. + """ + identifier = stub_identifier("prik_unused_adapter_stub", module_name) + return ( + "/* Generated by PRIK: this module needs no collision-adapter unit.\n" + " The file exists so the build system's source list stays fixed. */\n" + f"extern int {identifier};\n" + ) + + +def _fortran_stub_source(module_name: str) -> str: + """Return a placeholder Fortran unit that compiles to no reachable symbol.""" + identifier = stub_identifier("prik_unused_bridge_stub", module_name) + return ( + "! Generated by PRIK: this module needs no Fortran bridge unit.\n" + "! The file exists so the build system's source list stays fixed.\n" + f"module {identifier}\n" + " implicit none\n" + " private\n" + f"end module {identifier}\n" + ) + + def _generated_source_payloads( rendered: GeneratedWrapper, ) -> dict[Path, GeneratedSource]: @@ -1198,14 +1292,14 @@ def _build_generated_wrapper_extension( compiler: Compiler | None = None, compile_jobs: int | None = None, verbose: bool | int = False, - _plan_only: bool = False, + _declared_generated_sources: Iterable[str | Path] | None = None, + _depfile: str | Path | None = None, ) -> WrapperBuildResult: """Write, compile, and link one complete generated wrapper. - ``_plan_only`` preserves the completed wrapper and native plans while - returning their deterministic generated-source paths without writing, - compiling, or linking. Build integrations use that narrow query to declare - their own dependency graph before requesting normal source generation. + ``_declared_generated_sources`` names the generated compilation units a + build system already declared; any it names that this wrapper does not need + is written as a harmless placeholder so the declared file list stays exact. """ # Freeze the canonical wrapper before selecting materialization or planning. rendered.freeze() @@ -1240,43 +1334,34 @@ def _build_generated_wrapper_extension( ), ) extension_link_flags = _compiler_flags(wrapper_c_flags) - if _plan_only: - generated_sources = tuple(_generated_source_output_path(output_path, path) for path in rendered.generated_files) - native_support_imports = _generated_wrapper_native_support_imports(rendered.native_support_keys) - native_support_files = native_support_output_paths( - native_support_imports, - prik_dirpath=output_path, - ) - return WrapperBuildResult( - sources=tuple(Path(source) for source in sources), - module_name=rendered.module_name, - output_dir=output_path, - # This path is informational in a plan-only result; CMake owns the - # real extension suffix and output location. - shared_library=shared_output_path / f"{rendered.module_name}.so", - build_makefile=None, - compiled=False, - generated_sources=generated_sources, - generated_files=(*generated_sources, *native_support_files), - native_build_plan=resolved_native_build_plan, - native_generated_code_groups=rendered.native_generated_code_groups, - generated_compilation_units=generated_compilation_units, - semantic_dependencies=tuple(Path(path) for path in semantic_dependencies), - linker_language=linker_language, - extension_link_flags=extension_link_flags, - ) - output_path.mkdir(parents=True, exist_ok=True) shared_output_path.mkdir(parents=True, exist_ok=True) - _write_generated_wrapper_sources(rendered, output_path, verbose=verbose) + written_sources = _write_generated_wrapper_sources(rendered, output_path, verbose=verbose) + layout_stubs = () + if _declared_generated_sources is not None: + layout_stubs = _write_deterministic_layout_stubs( + rendered, + declared_sources=_declared_generated_sources, + verbose=verbose, + ) # Prepare generated-object inputs and their native support files. native_support_imports = _generated_wrapper_native_support_imports(rendered.native_support_keys) + if _declared_generated_sources is not None: + # A declared output has to exist however the wrapper turns out, so the + # header-only support payload is unconditional in this layout. + native_support_imports = tuple(dict.fromkeys((*native_support_imports, BINDING_SUPPORT_IMPORT))) install_native_support( native_support_imports, prik_dirpath=str(output_path), verbose=verbose, ) + if _depfile is not None: + write_generation_depfile( + _depfile, + outputs=(*written_sources, *layout_stubs), + dependencies=semantic_dependencies, + ) # Compile dependency-ready native sources, then the bridge and binding. _compile_extension_objects( @@ -3480,7 +3565,8 @@ def build_fortran_extension( wrapper_c_flags: Iterable[str] | None = None, standard_logicals: bool = True, _on_total_build_time: Callable[[float], None] | None = None, - _plan_only: bool = False, + _declared_generated_sources: Iterable[str | Path] | None = None, + _depfile: str | Path | None = None, _external_native_implementation: bool = False, ) -> WrapperBuildResult: """Build a Python extension from one or more Fortran source files. @@ -3578,7 +3664,7 @@ def build_fortran_extension( jobs=jobs, verbose=verbose, ) - if _external_native_implementation and not (generate_sources or _plan_only): + if _external_native_implementation and not generate_sources: raise ValueError("An external native implementation is valid only for source generation or planning") build_started = time.perf_counter() @@ -3586,8 +3672,7 @@ def build_fortran_extension( # 1. Collect the source and native implementation inputs. source_paths = _source_paths(sources) output_path, shared_library_output_path = _wrapper_output_paths(output_dir) - if not _plan_only: - output_path.mkdir(parents=True, exist_ok=True) + output_path.mkdir(parents=True, exist_ok=True) preprocessing = preprocessing or _default_preprocessing_config() supplemental_source_paths = tuple(Path(path) for path in (native_fortran_sources or ())) input_implementation_paths = source_paths if compile_input_sources else () @@ -3631,7 +3716,7 @@ def build_fortran_extension( collision_adapter_all=collision_adapter_all, positional_only=positional_only, ) - contract_files = () if _plan_only else _write_build_contract_package(source_modules, output_path, verbose=verbose) + contract_files = _write_build_contract_package(source_modules, output_path, verbose=verbose) # 4. Prepare native compilation, dependency batches, and link inputs. wrapper_fortran_flags = _compiler_flags(wrapper_fortran_flags) @@ -3665,7 +3750,8 @@ def build_fortran_extension( compiler=compiler, compile_jobs=1 if generation_only else compile_jobs, verbose=verbose, - _plan_only=_plan_only, + _declared_generated_sources=_declared_generated_sources, + _depfile=_depfile, ) result = _finalize_build_mode( result, @@ -3719,7 +3805,8 @@ def build_c_extension( wrapper_c_flags: Iterable[str] | None = None, standard_logicals: bool = True, _on_total_build_time: Callable[[float], None] | None = None, - _plan_only: bool = False, + _declared_generated_sources: Iterable[str | Path] | None = None, + _depfile: str | Path | None = None, _external_native_implementation: bool = False, ) -> WrapperBuildResult: """Build a direct-only C extension from explicit C implementation sources. @@ -3754,7 +3841,7 @@ def build_c_extension( jobs=jobs, verbose=verbose, ) - if _external_native_implementation and not (generate_sources or _plan_only): + if _external_native_implementation and not generate_sources: raise ValueError("An external native implementation is valid only for source generation or planning") build_started = time.perf_counter() selected_exports = None if export_symbols is None else tuple(export_symbols) @@ -3829,14 +3916,12 @@ def build_c_extension( collision_adapter_all=collision_adapter_all, positional_only=positional_only, ) - contract_files = () - if not _plan_only: - output_path.mkdir(parents=True, exist_ok=True) - contract_files = _write_build_contract_package( - tuple(_wrapped_c_translation_unit(module) for module in source_modules), - output_path, - verbose=verbose, - ) + output_path.mkdir(parents=True, exist_ok=True) + contract_files = _write_build_contract_package( + tuple(_wrapped_c_translation_unit(module) for module in source_modules), + output_path, + verbose=verbose, + ) native_source_objects, native_build_plan = _prepare_native_build_plan( native_inputs, output_path=output_path, @@ -3859,7 +3944,8 @@ def build_c_extension( compiler=compiler, compile_jobs=1 if generation_only else compile_jobs, verbose=verbose, - _plan_only=_plan_only, + _declared_generated_sources=_declared_generated_sources, + _depfile=_depfile, ) result = _finalize_build_mode( result, @@ -3910,7 +3996,8 @@ def build_pyi_extension( wrapper_c_flags: Iterable[str] | None = None, standard_logicals: bool = True, _on_total_build_time: Callable[[float], None] | None = None, - _plan_only: bool = False, + _declared_generated_sources: Iterable[str | Path] | None = None, + _depfile: str | Path | None = None, _external_native_implementation: bool = False, ) -> WrapperBuildResult: """Build a Python extension from an editable semantic ``.pyi`` contract. @@ -3989,7 +4076,7 @@ def build_pyi_extension( jobs=jobs, verbose=verbose, ) - if _external_native_implementation and not (generate_sources or _plan_only): + if _external_native_implementation and not generate_sources: raise ValueError("An external native implementation is valid only for source generation or planning") build_started = time.perf_counter() @@ -4049,8 +4136,7 @@ def build_pyi_extension( collision_adapter_all=collision_adapter_all, positional_only=positional_only, ) - if not _plan_only: - output_path.mkdir(parents=True, exist_ok=True) + output_path.mkdir(parents=True, exist_ok=True) # 3. Select the compiler profile and complete the native compilation plan. compiler = _new_compiler( @@ -4085,7 +4171,8 @@ def build_pyi_extension( compiler=compiler, compile_jobs=1 if generation_only else compile_jobs, verbose=verbose, - _plan_only=_plan_only, + _declared_generated_sources=_declared_generated_sources, + _depfile=_depfile, ) result = _with_pyi_manifest( result, diff --git a/prik/pipeline/wrapper.py b/prik/pipeline/wrapper.py index ad9c61eda..f5850d744 100644 --- a/prik/pipeline/wrapper.py +++ b/prik/pipeline/wrapper.py @@ -101,6 +101,7 @@ ) from prik.policy.native_array_handles import NATIVE_ARRAY_POINTER_C_DESCRIPTOR_HEADER from prik.codegen.c.binding import CBindingGenerator +from prik.naming.generated_files import wrapper_header_name from prik.codegen.docstrings import WrapperDocstringBuilder from prik.codegen.fortran.bridge import FortranBridgeGenerator from prik.planning.models import ( @@ -5774,7 +5775,7 @@ def _generated_wrapper( raise ValueError("Generated Fortran payload requires exactly one planned physical source") if fortran_source is None and bridge_sources: raise ValueError("Planned generated Fortran groups require a rendered source payload") - headers = (Path(f"{module_name}_wrapper.h"),) + headers = (Path(wrapper_header_name(module_name)),) # Preserve build-consumed source ordering: bridge, binding units, then header. return GeneratedWrapper( diff --git a/prik/planning/planner.py b/prik/planning/planner.py index 2f8074346..ab41dac7a 100644 --- a/prik/planning/planner.py +++ b/prik/planning/planner.py @@ -71,6 +71,7 @@ completed_function_wrapper_policy, completed_module_variable_policy, ) +from prik.naming.generated_files import bridge_source_name from prik.policy.exports import PythonExportPolicy from prik.policy.ownership import AssignmentMode, NativeBarrierAction, SetterAction from prik.planning.models import ( @@ -424,7 +425,7 @@ def _native_generated_code_groups( support_procedures: tuple[GeneratedSupportProcedureEntrypointPlan, ...], ) -> tuple[NativeGeneratedCodeGroupPlan, ...]: """Keep adapted-user and Fortran-support membership independently visible.""" - source_paths = (f"bind_c_{module_name}_wrapper.f90",) + source_paths = (bridge_source_name(module_name),) adapter_members = tuple( function.owner_path for namespace in namespaces diff --git a/tests/fortran/infrastructure/building/end_to_end/test_cmake_builds.py b/tests/fortran/infrastructure/building/end_to_end/test_cmake_builds.py index 2ac8cca55..b733ecc12 100644 --- a/tests/fortran/infrastructure/building/end_to_end/test_cmake_builds.py +++ b/tests/fortran/infrastructure/building/end_to_end/test_cmake_builds.py @@ -90,6 +90,23 @@ def _import_extension(module_name: str, build: Path): sys.path.remove(str(artifacts[0].parent)) +def _call_extension( + module_name: str, build: Path, expression: str, *, environment: dict[str, str] | None = None +) -> str: + """Import the built extension in a fresh interpreter and evaluate one call. + + A rebuilt extension cannot be re-imported in this process: CPython caches + an extension module for the life of the interpreter, so a second import + returns the shared library the first one loaded. + """ + artifacts = tuple(build.rglob(f"{module_name}*.so")) + assert artifacts, f"no CMake extension artifact in {build}" + environment = dict(environment or _environment()) + environment["PYTHONPATH"] = str(artifacts[0].parent) + os.pathsep + environment["PYTHONPATH"] + program = f"import numpy, {module_name}\nprint({expression})\n" + return _run([sys.executable, "-c", program], environment=environment).stdout.strip() + + def _call_with_unassisted_loader(module_name: str, build: Path, expression: str, *, native_library: Path) -> str: """Import the built extension with no loader path able to find its native library. @@ -97,23 +114,19 @@ def _call_with_unassisted_loader(module_name: str, build: Path, expression: str, a shared-libpython build needs it, but that environment is asserted not to resolve ``native_library``. Only the extension's own build rpath can. """ - artifacts = tuple(build.rglob(f"{module_name}*.so")) - assert artifacts, f"no CMake extension artifact in {build}" environment = _environment() for variable in ("LD_LIBRARY_PATH", "DYLD_LIBRARY_PATH", "DYLD_FALLBACK_LIBRARY_PATH"): searched = tuple(entry for entry in environment.get(variable, "").split(os.pathsep) if entry) assert not any((Path(entry) / native_library.name).exists() for entry in searched), ( f"{variable} already resolves {native_library.name}, so the import would not prove a build rpath" ) - environment["PYTHONPATH"] = str(artifacts[0].parent) + os.pathsep + environment["PYTHONPATH"] - program = f"import numpy, {module_name}\nprint({expression})\n" - return _run([sys.executable, "-c", program], environment=environment).stdout.strip() + return _call_extension(module_name, build, expression, environment=environment) def _write_project(project: Path, body: str, *, languages: str = "C Fortran") -> None: project.mkdir(parents=True, exist_ok=True) (project / "CMakeLists.txt").write_text( - f"""cmake_minimum_required(VERSION 3.20) + f"""cmake_minimum_required(VERSION 3.21) project(cmake_test LANGUAGES {languages}) find_package(Python COMPONENTS Interpreter Development.Module REQUIRED) list(APPEND CMAKE_MODULE_PATH "{USE_PRIK_DIR.as_posix()}") @@ -891,7 +904,8 @@ def test_use_prik_cmake_compiles_generated_fortran_bridge_and_binding(tmp_path: shutil.which("cmake") is None or shutil.which("gfortran") is None or shutil.which("gcc") is None, reason="CMake, gfortran, and gcc are required", ) -def test_use_prik_cmake_reconfigures_when_source_adds_a_fortran_bridge(tmp_path: Path): +def test_use_prik_cmake_keeps_the_bridge_filename_fixed_across_semantic_edits(tmp_path: Path): + """A semantic edit changes the bridge file's contents, never the build graph.""" project = tmp_path / "routing project" project.mkdir() source = project / "routing.f90" @@ -915,11 +929,16 @@ def test_use_prik_cmake_reconfigures_when_source_adds_a_fortran_bridge(tmp_path: ) build = project / "build" _configure_and_build(project, build, language="fortran") - generated = build / "prik" / "routing" - assert not tuple(generated.glob("*.f90")) + bridge = build / "prik" / "routing" / "bind_c_routing_wrapper.f90" + + # This module needs no bridge, but the declared source still exists so + # CMake's source list does not depend on semantic analysis. + assert bridge.is_file(), "the deterministic bridge source is missing" + assert "prik_unused_bridge_stub" in bridge.read_text(encoding="utf-8") module = _import_extension("routing", build) assert module.standalone_direct(np.int32(4)) == np.int32(6) + # Adding a procedure that needs a bridge must not require a reconfigure. source.write_text( """integer(c_int) function standalone_direct(value) bind(C, name="standalone_mixed_direct") result(output) use iso_c_binding @@ -938,7 +957,26 @@ def test_use_prik_cmake_reconfigures_when_source_adds_a_fortran_bridge(tmp_path: encoding="utf-8", ) _run(["cmake", "--build", str(build), "-j2"]) - assert tuple(generated.glob("*.f90")), "CMake did not reconfigure the generated bridge source set" + + bridge_text = bridge.read_text(encoding="utf-8") + assert "prik_unused_bridge_stub" not in bridge_text, "the bridge stub was not replaced by real content" + assert "bind_c_routing_wrapper" in bridge_text + assert _call_extension("routing", build, "routing.standalone_adapted(numpy.int32(4))") == "7" + + # ... and reverting is symmetric: real content becomes a stub again. + source.write_text( + """integer(c_int) function standalone_direct(value) bind(C, name="standalone_direct_symbol") result(output) + use iso_c_binding + integer(c_int), value, intent(in) :: value + + output = value + 2_c_int +end function standalone_direct +""", + encoding="utf-8", + ) + _run(["cmake", "--build", str(build), "-j2"]) + assert "prik_unused_bridge_stub" in bridge.read_text(encoding="utf-8") + assert _call_extension("routing", build, "routing.standalone_direct(numpy.int32(4))") == "6" @pytest.mark.fortran_end_to_end @@ -1122,6 +1160,231 @@ def test_use_prik_cmake_requires_fortran_for_native_fortran_sources(tmp_path: Pa assert "native Fortran sources" in result.stderr +@pytest.mark.fortran_end_to_end +@pytest.mark.skipif( + shutil.which("cmake") is None or shutil.which("gfortran") is None or shutil.which("gcc") is None, + reason="CMake, gfortran, and gcc are required", +) +def test_use_prik_cmake_configure_does_not_run_the_semantic_pipeline(tmp_path: Path): + """Configure plans structurally, so a source PRIK cannot parse still configures. + + The failure surfaces at build time, where the semantic pipeline actually + runs. That is the whole point of the split: CMake learns its source graph + without parsing, completing policy, or generating code. + """ + project = tmp_path / "unparsable project" + project.mkdir() + (project / "broken.f90").write_text( + "real(8) function broken(x) result(y)\n this is not valid Fortran at all @@@\n", + encoding="utf-8", + ) + _write_project( + project, + """prik_add_module( + broken + SOURCES broken.f90 +) +""", + ) + build = project / "build" + configure = subprocess.run( + [ + "cmake", + "-S", + str(project), + "-B", + str(build), + f"-DCMAKE_C_COMPILER={shutil.which('gcc')}", + f"-DCMAKE_Fortran_COMPILER={shutil.which('gfortran')}", + ], + env=_environment(), + capture_output=True, + text=True, + ) + assert configure.returncode == 0, f"configure ran semantic analysis:\n{configure.stderr}" + assert not tuple((build / "prik" / "broken").glob("*.c")), "configure generated wrapper sources" + + built = subprocess.run(["cmake", "--build", str(build), "-j2"], env=_environment(), capture_output=True, text=True) + assert built.returncode != 0, "the unparsable source should fail during build-time generation" + + +@pytest.mark.fortran_end_to_end +@pytest.mark.skipif(shutil.which("cmake") is None or shutil.which("gcc") is None, reason="CMake and gcc are required") +def test_use_prik_cmake_keeps_the_adapter_filename_fixed(tmp_path: Path): + """The adapter unit is declared either way: a stub without adapters, real content with them.""" + results = {} + for label, prik_args in (("plain", ""), ("adapted", " PRIK_ARGS --collision-adapter-all\n")): + project = tmp_path / f"adapter {label}" + project.mkdir() + (project / "capi.c").write_text("double capi_add(double value) { return value + 1.0; }\n", encoding="utf-8") + _write_project( + project, + f"""prik_add_module( + adapter_{label} + C_SOURCES capi.c +{prik_args}) +""", + languages="C", + ) + build = project / "build" + _configure_and_build(project, build, language="c") + adapters = build / "prik" / f"adapter_{label}" / f"adapter_{label}_adapters.c" + assert adapters.is_file(), f"the deterministic adapter source is missing for {label}" + results[label] = adapters.read_text(encoding="utf-8") + assert _call_extension(f"adapter_{label}", build, f"adapter_{label}.capi_add(numpy.float64(2.0))") == "3.0" + + assert "prik_unused_adapter_stub" in results["plain"] + assert "prik_unused_adapter_stub" not in results["adapted"] + assert "capi_add" in results["adapted"] + + +@pytest.mark.fortran_end_to_end +@pytest.mark.skipif( + shutil.which("cmake") is None or shutil.which("gfortran") is None or shutil.which("gcc") is None, + reason="CMake, gfortran, and gcc are required", +) +def test_use_prik_cmake_does_not_regenerate_an_unchanged_build(tmp_path: Path): + project = tmp_path / "idempotent project" + project.mkdir() + (project / "steady.f90").write_text( + "real(8) function steady(x) result(y)\n real(8), intent(in) :: x\n y = x\nend function steady\n", + encoding="utf-8", + ) + _write_project( + project, + """prik_add_module( + steady + SOURCES steady.f90 +) +""", + ) + build = project / "build" + _configure_and_build(project, build, language="fortran") + generation_comment = "Generate PRIK wrapper sources for steady" + + rebuilt = _run(["cmake", "--build", str(build), "-j2"]) + assert generation_comment not in rebuilt.stdout + rebuilt.stderr, "an unchanged build reran PRIK generation" + + +@pytest.mark.fortran_end_to_end +@pytest.mark.skipif( + shutil.which("gfortran") is None or shutil.which("gcc") is None, reason="gfortran and gcc are required" +) +def test_generated_placeholder_units_compile_under_strict_flags(tmp_path: Path): + """Unused optional units must not cost the caller a warning.""" + source = tmp_path / "strict.f90" + source.write_text( + 'integer(c_int) function strict(value) bind(C, name="strict_symbol") result(output)\n' + " use iso_c_binding\n" + " integer(c_int), value, intent(in) :: value\n" + " output = value\n" + "end function strict\n", + encoding="utf-8", + ) + out_dir = tmp_path / "generated" + _run( + [ + sys.executable, + "-m", + "prik", + "generate", + "--sources", + str(source), + "--module-name", + "strict", + "--declared-layout", + "--out-dir", + str(out_dir), + ] + ) + + adapter_stub = out_dir / "strict_adapters.c" + bridge_stub = out_dir / "bind_c_strict_wrapper.f90" + assert "prik_unused_adapter_stub" in adapter_stub.read_text(encoding="utf-8") + assert "prik_unused_bridge_stub" in bridge_stub.read_text(encoding="utf-8") + + _run( + [ + "gcc", + "-c", + "-Wall", + "-Wextra", + "-pedantic", + "-Werror", + "-std=c11", + "-fPIC", + str(adapter_stub), + "-o", + str(tmp_path / "adapter.o"), + ] + ) + _run( + [ + "gfortran", + "-c", + "-Wall", + "-Wextra", + "-pedantic", + "-Werror", + "-fPIC", + str(bridge_stub), + "-o", + str(tmp_path / "bridge.o"), + ], + cwd=tmp_path, + ) + + +@pytest.mark.fortran_end_to_end +@pytest.mark.skipif( + shutil.which("cmake") is None or shutil.which("gfortran") is None or shutil.which("gcc") is None, + reason="CMake, gfortran, and gcc are required", +) +def test_generate_cmake_lto_reaches_native_and_generated_compilation(tmp_path: Path): + """--lto must reach both target kinds, not only the CMakeLists initializer.""" + (tmp_path / "solver.f90").write_text( + "real(8) function solve(x) result(y)\n real(8), intent(in) :: x\n y = x * 2.0d0\nend function solve\n", + encoding="utf-8", + ) + (tmp_path / "helper.c").write_text("double helper_value(void) { return 1.0; }\n", encoding="utf-8") + project = tmp_path / "lto project" + _run( + [ + sys.executable, + "-m", + "prik", + "generate", + "--cmake", + str(tmp_path / "solver.f90"), + "--native-c-sources", + str(tmp_path / "helper.c"), + "--module-name", + "lto_module", + "--lto", + "--out-dir", + str(project), + ] + ) + + build = project / "build" + command = ["cmake", "-S", str(project), "-B", str(build), "-DCMAKE_EXPORT_COMPILE_COMMANDS=ON"] + if shutil.which("ninja"): + command.extend(("-G", "Ninja")) + command.append(f"-DCMAKE_C_COMPILER={shutil.which('gcc')}") + command.append(f"-DCMAKE_Fortran_COMPILER={shutil.which('gfortran')}") + _run(command) + _run(["cmake", "--build", str(build), "-j2"]) + + entries = json.loads((build / "compile_commands.json").read_text(encoding="utf-8")) + compiled = {Path(entry["file"]).name: entry["command"] for entry in entries} + # The caller's native sources and every generated unit must all carry it. + for source in ("solver.f90", "helper.c", "lto_module_wrapper.c", "bind_c_lto_module_wrapper.f90"): + assert source in compiled, f"{source} was not compiled" + assert "flto" in compiled[source], f"link-time optimization missing from {source}" + + assert _call_extension("lto_module", build, "lto_module.solve(numpy.float64(4.0))") == "8.0" + + @pytest.mark.fortran_end_to_end @pytest.mark.skipif(shutil.which("cmake") is None or shutil.which("gcc") is None, reason="CMake and gcc are required") def test_use_prik_cmake_propagates_dependency_usage_to_native_objects(tmp_path: Path): From 664c74c9f0a61c3a273905da3618405e00b054a1 Mon Sep 17 00:00:00 2001 From: said Date: Sun, 13 Sep 2026 07:15:49 +0100 Subject: [PATCH 09/15] codex: discover PRIK's CMake modules automatically Package the CMake helper inside the distribution as prik/cmake_modules so an entry point can name it, and register that directory under cmake.module. A scikit-build-core build then puts it on CMAKE_MODULE_PATH itself, so a project needs only include(UsePRIK). A new PRIKConfig.cmake beside the helper loads it for find_package(PRIK CONFIG REQUIRED), and both modules still install to share/prik/cmake, so cmake_module_dir() and that prefix layout are unchanged. Add `prik cmake-dir` and `prik install-dir` so a shell can substitute the packaged module directory or PRIK's installation prefix into -DPRIK_DIR= and -DCMAKE_PREFIX_PATH=. An installation prefix is not a CMake fact, so it lives in the new prik/installation.py, and it reports that PRIK is not installed rather than naming a prefix that holds none of its data. Fail the Ubuntu and macOS unit-test jobs when CMake is missing, since the CMake integration tests would otherwise deselect themselves and leave the job green. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KMz3kPhNruZUmNkDAMT9KJ --- .github/workflows/merge-validation.yml | 26 +++++ .github/workflows/tests.yml | 26 +++++ CHANGELOG.md | 13 +++ MANIFEST.in | 2 +- docs/user/guide/cmake.md | 44 +++++++++ docs/user/reference/cli-commands.md | 27 +++++- prik/cli.py | 61 +++++++++++- prik/cmake.py | 14 +-- prik/cmake_modules/PRIKConfig.cmake | 14 +++ {cmake => prik/cmake_modules}/UsePRIK.cmake | 0 prik/cmake_modules/__init__.py | 7 ++ prik/installation.py | 39 ++++++++ pyproject.toml | 11 ++- .../_support/installed_distribution.py | 92 ++++++++++++++++++ .../fortran/infrastructure/building/README.md | 3 +- .../building/end_to_end/test_cmake_builds.py | 96 +++++++++---------- .../building/pipeline/test_cmake_packaging.py | 70 ++++++++++++++ .../cli/pipeline/test_stage_dispatch.py | 36 +++++++ 18 files changed, 513 insertions(+), 68 deletions(-) create mode 100644 prik/cmake_modules/PRIKConfig.cmake rename {cmake => prik/cmake_modules}/UsePRIK.cmake (100%) create mode 100644 prik/cmake_modules/__init__.py create mode 100644 prik/installation.py create mode 100644 tests/fortran/_support/installed_distribution.py create mode 100644 tests/fortran/infrastructure/building/pipeline/test_cmake_packaging.py diff --git a/.github/workflows/merge-validation.yml b/.github/workflows/merge-validation.yml index 979db129e..3e5df9ad6 100644 --- a/.github/workflows/merge-validation.yml +++ b/.github/workflows/merge-validation.yml @@ -281,6 +281,19 @@ jobs: ln -sf "$(command -v "$PRIK_GFORTRAN_BINARY")" "$compiler_dir/gfortran" echo "$compiler_dir" >> "$GITHUB_PATH" "$compiler_dir/gfortran" --version + - name: Verify CMake build tooling + shell: bash + run: | + if ! command -v cmake >/dev/null 2>&1; then + echo "cmake is missing, so the CMake integration tests would deselect themselves" >&2 + exit 1 + fi + cmake --version + if command -v ninja >/dev/null 2>&1; then + ninja --version + else + echo "::warning::ninja is missing; CMake tests fall back to the Makefile generator" + fi - name: Run tests if: ${{ !matrix.coverage }} shell: bash @@ -401,6 +414,19 @@ jobs: gfortran --version command -v gcc gcc --version + - name: Verify CMake build tooling + shell: bash + run: | + if ! command -v cmake >/dev/null 2>&1; then + echo "cmake is missing, so the CMake integration tests would deselect themselves" >&2 + exit 1 + fi + cmake --version + if command -v ninja >/dev/null 2>&1; then + ninja --version + else + echo "::warning::ninja is missing; CMake tests fall back to the Makefile generator" + fi - name: Show repository-owned lane plan run: | python tools/run_fortran_toolchain_lane.py \ diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 28735ee60..3bb8f3574 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -45,6 +45,19 @@ jobs: ln -sf "$(command -v "$PRIK_GFORTRAN_BINARY")" "$compiler_dir/gfortran" echo "$compiler_dir" >> "$GITHUB_PATH" "$compiler_dir/gfortran" --version + - name: Verify CMake build tooling + shell: bash + run: | + if ! command -v cmake >/dev/null 2>&1; then + echo "cmake is missing, so the CMake integration tests would deselect themselves" >&2 + exit 1 + fi + cmake --version + if command -v ninja >/dev/null 2>&1; then + ninja --version + else + echo "::warning::ninja is missing; CMake tests fall back to the Makefile generator" + fi - name: Run tests shell: bash env: @@ -122,6 +135,19 @@ jobs: gfortran --version command -v gcc gcc --version + - name: Verify CMake build tooling + shell: bash + run: | + if ! command -v cmake >/dev/null 2>&1; then + echo "cmake is missing, so the CMake integration tests would deselect themselves" >&2 + exit 1 + fi + cmake --version + if command -v ninja >/dev/null 2>&1; then + ninja --version + else + echo "::warning::ninja is missing; CMake tests fall back to the Makefile generator" + fi - name: Show repository-owned lane plan run: | python tools/run_fortran_toolchain_lane.py \ diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e782daf7..768304438 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -58,6 +58,19 @@ release tags add a leading `v` to the package version. - `prik-build.json` schema 5 records generated/native compilation-unit ABI flags and explicit native linker-language requirements. +- PRIK's CMake modules are packaged inside the `prik` distribution and + discovered automatically. scikit-build-core builds pick the directory up + through a `cmake.module` entry point, so `include(UsePRIK)` needs no lookup, + and a packaged `PRIKConfig.cmake` makes `find_package(PRIK CONFIG REQUIRED)` + load the same helper. `prik cmake-dir` prints the packaged module directory + and `prik install-dir` the prefix PRIK's data files are installed under, for + `-DPRIK_DIR=` and `-DCMAKE_PREFIX_PATH=`. `cmake_module_dir()` and the + `share/prik/cmake` installation are unchanged. + +- The Ubuntu and macOS unit-test jobs now fail when CMake is missing instead of + letting the CMake integration tests deselect themselves, and report a missing + Ninja as a warning because those tests then use the Makefile generator. + - Array handles support allocatable and pointer arguments, results, module variables, derived fields, optional arguments, and matching ordinary-array parameters. Numeric and character arrays accept supported forward and diff --git a/MANIFEST.in b/MANIFEST.in index 6792ee98f..222b1f866 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,4 +1,4 @@ include CHANGELOG.md include CITATION.cff include .artifacts/.gitignore -recursive-include cmake *.cmake +recursive-include prik/cmake_modules *.cmake diff --git a/docs/user/guide/cmake.md b/docs/user/guide/cmake.md index a09fb2473..f3a2a285f 100644 --- a/docs/user/guide/cmake.md +++ b/docs/user/guide/cmake.md @@ -86,6 +86,50 @@ cmake -S . -B build cmake --build build ``` +### Other ways to find the helper + +With [scikit-build-core](https://scikit-build-core.readthedocs.io/), PRIK's +packaged CMake directory reaches `CMAKE_MODULE_PATH` through its `cmake.module` +entry point, so the project needs only the include: + +```cmake +include(UsePRIK) +``` + +PRIK also packages `PRIKConfig.cmake` beside the helper, so any project can load +it as a CMake package instead: + +```cmake +find_package(PRIK CONFIG REQUIRED) + +prik_add_module( + physics + FORTRAN_SOURCES + solver.f90 +) +``` + +Point CMake at PRIK when configuring. `prik cmake-dir` prints the packaged +module directory, which is where `PRIKConfig.cmake` lives: + +```bash +cmake -S . -B build -DPRIK_DIR="$(prik cmake-dir)" +``` + +`prik install-dir` prints the prefix PRIK is installed under, which carries the +same modules in `share/prik/cmake` and also resolves everything else installed +there: + +```bash +cmake -S . -B build -DCMAKE_PREFIX_PATH="$(prik install-dir)" +``` + +A source checkout installs nothing, so `install-dir` reports that instead of +naming a prefix; `cmake-dir` always answers. + +`find_package(PRIK CONFIG REQUIRED)` provides exactly what `include(UsePRIK)` +provides. + `prik_add_module()` also accepts `SOURCES` for source-first input, `CONTRACT` with `FORTRAN_SOURCES` or `C_SOURCES` for an authored semantic `.pyi`, `INCLUDE_DIRS`, `MODULE_DIRS`, native and generated-source compile flag groups, diff --git a/docs/user/reference/cli-commands.md b/docs/user/reference/cli-commands.md index 4660aa1a4..3493e597c 100644 --- a/docs/user/reference/cli-commands.md +++ b/docs/user/reference/cli-commands.md @@ -10,11 +10,12 @@ publication: reviewed # CLI Commands Reference With no subcommand, PRIK builds a wrapper. Four subcommands expose the earlier -stages without building one. +stages without building one, and two print paths another tool builds against. ```bash python3 -m prik INPUT [INPUT ...] [BUILD OPTIONS] python3 -m prik {parse,semantics,generate,probe} [OPTIONS] ... +python3 -m prik {cmake-dir,install-dir} ``` | Command | Purpose | @@ -24,6 +25,8 @@ python3 -m prik {parse,semantics,generate,probe} [OPTIONS] ... | `semantics` | Prints a human-readable semantic-IR report; `--json` selects the complete JSON record. | | `generate` | Writes `.pyi` contracts, wrapper sources, a Makefile, or a CMake project without compiling. | | `probe` | Prints compiler-target datatype and ABI facts. | +| `cmake-dir` | Prints the directory holding PRIK's packaged CMake modules. | +| `install-dir` | Prints the prefix holding PRIK's installed data files. | ## Getting help @@ -284,6 +287,28 @@ options only, because its inventory is fixed and preprocessing cannot change it; `-I`, `-D`, `-U`, and `--std` apply to `--expr` measurements, which are compiled from generated source. +## Paths + +`cmake-dir` and `install-dir` print one path each, so a shell can substitute +them straight into another tool's command line. Neither takes options or reads +a source file. + +```bash +python3 -m prik cmake-dir +python3 -m prik install-dir + +cmake -S . -B build -DPRIK_DIR="$(prik cmake-dir)" +cmake -S . -B build -DCMAKE_PREFIX_PATH="$(prik install-dir)" +``` + +| Command | Prints | +| --- | --- | +| `cmake-dir` | The directory holding `UsePRIK.cmake` and `PRIKConfig.cmake`. Use it as `PRIK_DIR`, or add it to `CMAKE_MODULE_PATH` for `include(UsePRIK)`. | +| `install-dir` | The prefix PRIK's data files are installed under, which holds the same CMake modules in `share/prik/cmake`. Use it in `CMAKE_PREFIX_PATH`. A source checkout installs nothing, so the command reports that rather than naming a prefix. | + +Both make `find_package(PRIK CONFIG REQUIRED)` resolve. See the +[CMake builds guide](../guide/cmake.md) for the project side. + ## Compiler preprocessing These options control preprocessing before parsing. diff --git a/prik/cli.py b/prik/cli.py index d71761baa..60a7747a6 100644 --- a/prik/cli.py +++ b/prik/cli.py @@ -51,6 +51,7 @@ _TOP_LEVEL_USAGE = ( "%(prog)s INPUT [INPUT ...] [BUILD OPTIONS]\n" " %(prog)s {parse,semantics,generate,probe} [OPTIONS] ...\n" + " %(prog)s {cmake-dir,install-dir}\n" " %(prog)s --version" ) _BUILD_USAGE = ( @@ -68,6 +69,14 @@ " --build-manifest PATH [OVERRIDES]" ) _PROBE_USAGE = "%(prog)s --language {fortran,c} --compiler COMPILER [OPTIONS]" +_PATH_HELP_EPILOG = ( + f"{_HELP_DIVIDER}\n\n" + " Load PRIK's CMake package from the packaged module directory:\n" + ' cmake -S . -B build -DPRIK_DIR="$(prik cmake-dir)"\n\n' + " Search PRIK's installation prefix instead:\n" + ' cmake -S . -B build -DCMAKE_PREFIX_PATH="$(prik install-dir)"\n\n' + " Both make find_package(PRIK CONFIG REQUIRED) and include(UsePRIK) work." +) _POINTS_EXAMPLE_HELP = ( " See the PRIK homepage for the points.f90 source and generated Python API:\n" " https://pynumlab.github.io/prik/#see-it-in-action\n" @@ -75,10 +84,12 @@ _CLI_HELP_DESCRIPTION = ( "Build Python extensions from Fortran or supported C APIs and inspect native interface artifacts.\n\n" "commands:\n" - " parse Inspect source declarations and parser facts\n" - " semantics Convert source code to language-neutral semantic IR\n" - " generate Generate contracts or wrapper build files\n" - " probe Probe compiler-target datatype and ABI facts" + " parse Inspect source declarations and parser facts\n" + " semantics Convert source code to language-neutral semantic IR\n" + " generate Generate contracts or wrapper build files\n" + " probe Probe compiler-target datatype and ABI facts\n" + " cmake-dir Print the directory holding PRIK's packaged CMake modules\n" + " install-dir Print the prefix holding PRIK's installed data files" ) _CLI_HELP_EPILOG = ( f"{_HELP_DIVIDER}\n\n" @@ -89,6 +100,8 @@ " python3 -m prik points.f90 --out geometry\n\n" " Generate an editable semantic contract:\n" " python3 -m prik generate --pyi points.f90 --out contracts\n\n" + " Point a CMake project at PRIK:\n" + ' cmake -S . -B build -DPRIK_DIR="$(prik cmake-dir)"\n\n' f"{_POINTS_EXAMPLE_HELP}\n" " More help:\n" " python3 -m prik --help-build\n" @@ -2921,6 +2934,36 @@ def _generate_parser(argv: list[str]) -> argparse.ArgumentParser: return parser +def _path_parser(command: str, description: str) -> Callable[[list[str]], argparse.ArgumentParser]: + """Build the parser for one path-printing command.""" + + def parser_for(argv: list[str]) -> argparse.ArgumentParser: + parser = _new_cli_parser( + prog=f"python3 -m prik {command}", + usage="%(prog)s", + description=description, + epilog=_PATH_HELP_EPILOG, + argv=argv, + ) + parser.set_defaults(command=command) + return parser + + return parser_for + + +def _run_path_command(args: argparse.Namespace, parser: argparse.ArgumentParser) -> int: + """Print one path for a shell to substitute into another tool's command line.""" + from prik.cmake import cmake_module_dir + from prik.installation import install_dir + + resolve = cmake_module_dir if args.command == "cmake-dir" else install_dir + try: + print(resolve()) + except FileNotFoundError as exc: + parser.error(str(exc)) + return 0 + + def _probe_parser(argv: list[str]) -> argparse.ArgumentParser: parser = _new_cli_parser( prog="python3 -m prik probe", @@ -3018,6 +3061,14 @@ def _probe_parser(argv: list[str]) -> argparse.ArgumentParser: "semantics": _semantics_parser, "generate": _generate_parser, "probe": _probe_parser, + "cmake-dir": _path_parser( + "cmake-dir", + "Print the directory holding PRIK's packaged CMake modules.", + ), + "install-dir": _path_parser( + "install-dir", + "Print the prefix holding PRIK's installed data files.", + ), } @@ -3125,6 +3176,8 @@ def main(argv: list[str] | None = None) -> int: return 0 if args.command == "probe": return _run_probe_command(args, parser) + if args.command in {"cmake-dir", "install-dir"}: + return _run_path_command(args, parser) args.language = _resolve_language(args.paths, args.language, parser) preprocessing = _build_preprocessing_config(args, parser) print_limit = _validate_main_options(args, parser) diff --git a/prik/cmake.py b/prik/cmake.py index dd767c079..e31c5362a 100644 --- a/prik/cmake.py +++ b/prik/cmake.py @@ -7,9 +7,8 @@ import os from pathlib import Path import shlex -import sys -import sysconfig +from prik.installation import data_roots from prik.naming.generated_files import ( adapter_source_name, binding_source_name, @@ -178,16 +177,13 @@ def _relative_path(path: Path, base: Path) -> str: def _helper_path() -> Path: - """Find the helper in a checkout or in the installed data directory.""" - candidates = [Path(__file__).resolve().parent.parent / "cmake" / "UsePRIK.cmake"] - data_root = sysconfig.get_path("data") - if data_root: - candidates.append(Path(data_root) / "share" / "prik" / "cmake" / "UsePRIK.cmake") - candidates.append(Path(sys.prefix) / "share" / "prik" / "cmake" / "UsePRIK.cmake") + """Find the helper in the packaged module directory or an installed data directory.""" + candidates = [Path(__file__).resolve().parent / "cmake_modules" / "UsePRIK.cmake"] + candidates.extend(root / "share" / "prik" / "cmake" / "UsePRIK.cmake" for root in data_roots()) for candidate in candidates: if candidate.is_file(): return candidate.resolve() - raise FileNotFoundError("Packaged PRIK CMake helper not found: cmake/UsePRIK.cmake") + raise FileNotFoundError("Packaged PRIK CMake helper not found: prik/cmake_modules/UsePRIK.cmake") def cmake_module_dir() -> Path: diff --git a/prik/cmake_modules/PRIKConfig.cmake b/prik/cmake_modules/PRIKConfig.cmake new file mode 100644 index 000000000..28a242996 --- /dev/null +++ b/prik/cmake_modules/PRIKConfig.cmake @@ -0,0 +1,14 @@ +#[=======================================================================[.rst: + +PRIKConfig +---------- + +Provide PRIK's packaged CMake helper through ``find_package(PRIK CONFIG)``. + +This loads ``UsePRIK.cmake`` from the same directory, so a project that finds +the package gets exactly what ``include(UsePRIK)`` provides, including +``prik_add_module()``. + +]=======================================================================] + +include("${CMAKE_CURRENT_LIST_DIR}/UsePRIK.cmake") diff --git a/cmake/UsePRIK.cmake b/prik/cmake_modules/UsePRIK.cmake similarity index 100% rename from cmake/UsePRIK.cmake rename to prik/cmake_modules/UsePRIK.cmake diff --git a/prik/cmake_modules/__init__.py b/prik/cmake_modules/__init__.py new file mode 100644 index 000000000..4fdabc40c --- /dev/null +++ b/prik/cmake_modules/__init__.py @@ -0,0 +1,7 @@ +"""Packaged CMake modules for PRIK's CMake integration. + +The directory holding this package is what ``include(UsePRIK)`` and +``find_package(PRIK CONFIG)`` need on ``CMAKE_MODULE_PATH`` or ``PRIK_DIR``. +Keeping the modules in an importable package lets a build backend discover them +through the ``cmake.module`` entry point without running PRIK. +""" diff --git a/prik/installation.py b/prik/installation.py new file mode 100644 index 000000000..e59db7453 --- /dev/null +++ b/prik/installation.py @@ -0,0 +1,39 @@ +"""Filesystem facts about the installation PRIK runs from. + +Installed data files -- the packaged CMake modules today, whatever a later +tool needs beside them -- all land under one prefix, so the question "where is +PRIK installed" belongs here rather than with any single consumer. +""" + +from __future__ import annotations + +from pathlib import Path +import sys +import sysconfig + + +def data_roots() -> tuple[Path, ...]: + """Return the prefixes an installation's ``share/prik`` data can sit under.""" + data_root = sysconfig.get_path("data") + roots = [Path(data_root)] if data_root else [] + roots.append(Path(sys.prefix)) + unique: list[Path] = [] + for root in roots: + if root not in unique: + unique.append(root) + return tuple(unique) + + +def install_dir() -> Path: + """Return the prefix holding this environment's installed PRIK data files. + + A source checkout installs nothing, so there is no such prefix and none is + invented: an answer that named a directory without PRIK's data in it would + only fail later, inside whichever tool consumed it. + """ + roots = data_roots() + for root in roots: + if (root / "share" / "prik").is_dir(): + return root + searched = ", ".join(str(root) for root in roots) + raise FileNotFoundError(f"PRIK is not installed with data files; no share/prik under: {searched}") diff --git a/pyproject.toml b/pyproject.toml index 397ecce70..f6617bb48 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -86,13 +86,22 @@ include = ["prik*"] [tool.setuptools.package-data] "prik.runtime.native_support" = ["*.h", "LICENSE"] +"prik.cmake_modules" = ["*.cmake"] [tool.setuptools.data-files] -"share/prik/cmake" = ["cmake/UsePRIK.cmake"] +"share/prik/cmake" = [ + "prik/cmake_modules/PRIKConfig.cmake", + "prik/cmake_modules/UsePRIK.cmake", +] [project.scripts] prik = "prik.cli:main" +# scikit-build-core adds the directory named here to CMAKE_MODULE_PATH, so a +# project it builds can include(UsePRIK) without locating PRIK itself. +[project.entry-points."cmake.module"] +prik = "prik.cmake_modules" + [project.urls] Homepage = "https://pynumlab.github.io/prik/" Documentation = "https://pynumlab.github.io/prik/" diff --git a/tests/fortran/_support/installed_distribution.py b/tests/fortran/_support/installed_distribution.py new file mode 100644 index 000000000..7c0c4b6a0 --- /dev/null +++ b/tests/fortran/_support/installed_distribution.py @@ -0,0 +1,92 @@ +"""Install a wheel built from the checkout for packaging-facing tests. + +What a distribution ships and what it advertises to other build tools are +properties of an installation, not of the source tree, so a test that makes +such a claim must ask an installed interpreter. Building and installing the +wheel once per session keeps that evidence affordable. +""" + +import os +import subprocess +import sys +import venv +from functools import cache +from pathlib import Path +from tempfile import TemporaryDirectory + +import pytest + +from tests.fortran._support.paths import REPO_ROOT + + +UNAVAILABLE_MARKERS = ( + "No module named pip", + "No module named build", + "No matching distribution found", + "Could not find a version that satisfies", + "Could not fetch URL", + "Temporary failure in name resolution", + "Network is unreachable", + "Connection timed out", +) + +_INSTALLATIONS: list[TemporaryDirectory] = [] + + +def clean_environment() -> dict[str, str]: + """Return an environment that cannot reach the checkout through PYTHONPATH.""" + environment = os.environ.copy() + environment.pop("PYTHONPATH", None) + return environment + + +@cache +def installed_prik_python() -> Path: + """Return the interpreter of an environment holding a freshly built wheel.""" + installation = TemporaryDirectory(prefix="prik-installed-wheel-") + _INSTALLATIONS.append(installation) + root = Path(installation.name) + distribution_dir = root / "dist" + environment = clean_environment() + wheel_build = subprocess.run( + [sys.executable, "-m", "pip", "wheel", "--no-deps", "--wheel-dir", str(distribution_dir), "."], + cwd=REPO_ROOT, + env=environment, + capture_output=True, + text=True, + ) + if wheel_build.returncode != 0: + wheel_output = wheel_build.stderr.strip() or wheel_build.stdout.strip() + if any(marker.lower() in wheel_output.lower() for marker in UNAVAILABLE_MARKERS): + pytest.skip(f"isolated wheel construction is unavailable: {wheel_output}") + pytest.fail(f"isolated wheel construction failed:\n{wheel_output}") + wheels = tuple(distribution_dir.glob("prik-*.whl")) + if not wheels: + pytest.skip("isolated wheel construction produced no wheel") + environment_dir = root / "installed" + venv.EnvBuilder(with_pip=True, system_site_packages=True).create(environment_dir) + installed_python = environment_dir / "bin" / "python" + install = subprocess.run( + [str(installed_python), "-m", "pip", "install", "--no-deps", str(wheels[0])], + env=environment, + capture_output=True, + text=True, + ) + if install.returncode != 0: + pytest.fail(f"installing the built wheel failed:\n{install.stderr.strip() or install.stdout.strip()}") + return installed_python + + +def installed_run(*command: str) -> str: + """Return what one command prints from inside the installed environment.""" + result = subprocess.run(command, env=clean_environment(), capture_output=True, text=True) + if result.returncode != 0: + raise AssertionError( + f"installed command failed: {' '.join(command)}\nstdout:\n{result.stdout}\nstderr:\n{result.stderr}" + ) + return result.stdout + + +def installed_output(program: str) -> str: + """Return what one program prints from the installed interpreter.""" + return installed_run(str(installed_prik_python()), "-I", "-c", program) diff --git a/tests/fortran/infrastructure/building/README.md b/tests/fortran/infrastructure/building/README.md index 3e2f9a7ab..5e6df0401 100644 --- a/tests/fortran/infrastructure/building/README.md +++ b/tests/fortran/infrastructure/building/README.md @@ -10,7 +10,8 @@ Evidence is split by the stage that establishes it: - `compiling/` checks compiler and linker command behavior; - `pipeline/` checks generated contracts, manifests, Makefiles, explicit native - inputs, and source-free `.pyi` build transitions; and + inputs, source-free `.pyi` build transitions, and the CMake modules an + installed distribution exposes; and - `end_to_end/` compiles and imports source, multi-source, runtime-compatibility, and mixed-native-bundle extensions. diff --git a/tests/fortran/infrastructure/building/end_to_end/test_cmake_builds.py b/tests/fortran/infrastructure/building/end_to_end/test_cmake_builds.py index b733ecc12..35740a6a4 100644 --- a/tests/fortran/infrastructure/building/end_to_end/test_cmake_builds.py +++ b/tests/fortran/infrastructure/building/end_to_end/test_cmake_builds.py @@ -10,14 +10,15 @@ import subprocess import sys import tempfile -import venv import numpy as np import pytest +from tests.fortran._support.installed_distribution import clean_environment, installed_prik_python + REPOSITORY_ROOT = Path(__file__).resolve().parents[5] -USE_PRIK_DIR = REPOSITORY_ROOT / "cmake" +USE_PRIK_DIR = REPOSITORY_ROOT / "prik" / "cmake_modules" BRIDGE_CONTRACT = ( REPOSITORY_ROOT / "tests" @@ -65,6 +66,7 @@ def _configure_and_build( build_project: bool = True, environment: dict[str, str] | None = None, python_executable: Path | None = None, + defines: tuple[str, ...] = (), ) -> None: command = ["cmake", "-S", str(project), "-B", str(build)] if use_ninja and shutil.which("ninja"): @@ -74,6 +76,7 @@ def _configure_and_build( command.append(f"-DCMAKE_Fortran_COMPILER={shutil.which('gfortran')}") if python_executable is not None: command.append(f"-DPython_EXECUTABLE={python_executable}") + command.extend(f"-D{define}" for define in defines) _run(command, environment=environment) if build_project: _run(["cmake", "--build", str(build), "-j2"], environment=environment) @@ -1796,57 +1799,48 @@ def test_use_prik_cmake_links_a_cmake_discovered_blas_target(tmp_path: Path): @pytest.mark.fortran_end_to_end -@pytest.mark.slow @pytest.mark.skipif( shutil.which("cmake") is None or shutil.which("gfortran") is None or shutil.which("gcc") is None, reason="CMake, gfortran, and gcc are required", ) -def test_installed_wheel_discovers_and_builds_with_use_prik(tmp_path: Path): - distribution_dir = tmp_path / "dist" - clean_environment = os.environ.copy() - clean_environment.pop("PYTHONPATH", None) - wheel_build = subprocess.run( - [ - sys.executable, - "-m", - "pip", - "wheel", - "--no-deps", - "--wheel-dir", - str(distribution_dir), - ".", - ], - cwd=REPOSITORY_ROOT, - env=clean_environment, - capture_output=True, - text=True, +def test_find_package_prik_config_provides_the_module_helper(tmp_path: Path): + project = tmp_path / "found package" + project.mkdir() + (project / "square.f90").write_text( + """real(8) function config_square(x) result(y) + real(8), intent(in) :: x + y = x * x +end function config_square +""", + encoding="utf-8", ) - if wheel_build.returncode != 0: - wheel_output = wheel_build.stderr.strip() or wheel_build.stdout.strip() - unavailable_markers = ( - "No module named pip", - "No module named build", - "No matching distribution found", - "Could not find a version that satisfies", - "Could not fetch URL", - "Temporary failure in name resolution", - "Network is unreachable", - "Connection timed out", - ) - if any(marker.lower() in wheel_output.lower() for marker in unavailable_markers): - pytest.skip(f"isolated wheel construction is unavailable: {wheel_output}") - pytest.fail(f"isolated wheel construction failed:\n{wheel_output}") - wheels = tuple(distribution_dir.glob("prik-*.whl")) - if not wheels: - pytest.skip("isolated wheel construction produced no wheel") - wheel = wheels[0] - environment_dir = tmp_path / "installed" - venv.EnvBuilder(with_pip=True, system_site_packages=True).create(environment_dir) - installed_python = environment_dir / "bin" / "python" - _run( - [str(installed_python), "-m", "pip", "install", "--no-deps", str(wheel)], - environment=clean_environment, + (project / "CMakeLists.txt").write_text( + """cmake_minimum_required(VERSION 3.21) +project(cmake_test LANGUAGES C Fortran) +find_package(Python COMPONENTS Interpreter Development.Module REQUIRED) +find_package(PRIK CONFIG REQUIRED) +prik_add_module( + config_square + SOURCES square.f90 +) +""", + encoding="utf-8", ) + build = project / "build" + _configure_and_build(project, build, language="fortran", defines=(f"PRIK_DIR={USE_PRIK_DIR.as_posix()}",)) + module = _import_extension("config_square", build) + assert module.config_square(np.float64(3.0)) == np.float64(9.0) + + +@pytest.mark.fortran_end_to_end +@pytest.mark.slow +@pytest.mark.skipif( + shutil.which("cmake") is None or shutil.which("gfortran") is None or shutil.which("gcc") is None, + reason="CMake, gfortran, and gcc are required", +) +def test_installed_wheel_discovers_and_builds_with_use_prik(tmp_path: Path): + installed_environment = clean_environment() + installed_python = installed_prik_python() discovery = _run( [ str(installed_python), @@ -1854,7 +1848,7 @@ def test_installed_wheel_discovers_and_builds_with_use_prik(tmp_path: Path): "-c", "from prik.cmake import cmake_module_dir; print(cmake_module_dir() / 'UsePRIK.cmake')", ], - environment=clean_environment, + environment=installed_environment, ) helper = Path(discovery.stdout.strip()) assert helper.is_file() @@ -1881,14 +1875,14 @@ def test_installed_wheel_discovers_and_builds_with_use_prik(tmp_path: Path): "--out-dir", str(project), ], - environment=clean_environment, + environment=installed_environment, ) build = project / "build" _configure_and_build( project, build, language="fortran", - environment=clean_environment, + environment=installed_environment, python_executable=installed_python, ) artifact = next(build.rglob("installed_square*.so")) @@ -1902,6 +1896,6 @@ def test_installed_wheel_discovers_and_builds_with_use_prik(tmp_path: Path): "assert installed_square.installed_square(numpy.float64(3.0)) == 9.0", ], cwd=artifact.parent, - environment=clean_environment, + environment=installed_environment, ) assert imported.returncode == 0 diff --git a/tests/fortran/infrastructure/building/pipeline/test_cmake_packaging.py b/tests/fortran/infrastructure/building/pipeline/test_cmake_packaging.py new file mode 100644 index 000000000..adef3e7ca --- /dev/null +++ b/tests/fortran/infrastructure/building/pipeline/test_cmake_packaging.py @@ -0,0 +1,70 @@ +"""Installed-distribution contracts for PRIK's packaged CMake modules. + +A CMake project finds the helper in an installation, either through PRIK's own +``cmake_module_dir()``, through ``find_package(PRIK CONFIG)``, or through the +``cmake.module`` entry point a build backend reads. All three need the packaged +modules to survive packaging, which only an installed wheel can show. +""" + +from pathlib import Path + +import pytest + +from tests.fortran._support.installed_distribution import installed_output, installed_prik_python, installed_run +from tests.fortran._support.paths import REPO_ROOT + + +@pytest.mark.slow +def test_installed_distribution_ships_prik_config_beside_use_prik() -> None: + module_dir, data_dir = ( + Path(line) + for line in installed_output( + "import sysconfig\n" + "from prik.cmake import cmake_module_dir\n" + "print(cmake_module_dir())\n" + "print(sysconfig.get_path('data'))\n" + ).splitlines() + ) + + assert REPO_ROOT not in module_dir.parents + for directory in (module_dir, data_dir / "share" / "prik" / "cmake"): + assert (directory / "UsePRIK.cmake").is_file(), directory + assert (directory / "PRIKConfig.cmake").is_file(), directory + + +@pytest.mark.slow +def test_installed_distribution_exposes_the_cmake_module_entry_point() -> None: + """The entry point resolves the way a scikit-build-core build reads it.""" + entry_point_dir = Path( + installed_output( + "import os\n" + "from importlib import metadata, resources\n" + "from prik.cmake import cmake_module_dir\n" + "modules = [\n" + " entry\n" + " for entry in metadata.distribution('prik').entry_points\n" + " if entry.group == 'cmake.module'\n" + "]\n" + "assert len(modules) == 1, modules\n" + "directory = os.path.realpath(str(resources.files(modules[0].load())))\n" + "assert directory == os.path.realpath(str(cmake_module_dir())), directory\n" + "print(directory)\n" + ).strip() + ) + + assert REPO_ROOT not in entry_point_dir.parents + assert (entry_point_dir / "UsePRIK.cmake").is_file() + + +@pytest.mark.slow +def test_installed_console_script_prints_the_paths_a_build_configures_with() -> None: + """``prik cmake-dir`` and ``prik install-dir`` answer for the installation they run from.""" + script = installed_prik_python().parent / "prik" + module_dir = Path(installed_run(str(script), "cmake-dir").strip()) + prefix = Path(installed_run(str(script), "install-dir").strip()) + + assert (module_dir / "PRIKConfig.cmake").is_file() + # macOS puts a temporary environment behind a /var -> /private/var symlink, + # so the environment root and the reported prefix are compared resolved. + assert prefix.resolve() == installed_prik_python().parent.parent.resolve() + assert (prefix / "share" / "prik" / "cmake" / "PRIKConfig.cmake").is_file() diff --git a/tests/fortran/infrastructure/cli/pipeline/test_stage_dispatch.py b/tests/fortran/infrastructure/cli/pipeline/test_stage_dispatch.py index 663946a5a..ce0ca4faf 100644 --- a/tests/fortran/infrastructure/cli/pipeline/test_stage_dispatch.py +++ b/tests/fortran/infrastructure/cli/pipeline/test_stage_dispatch.py @@ -10,6 +10,7 @@ import pytest +from prik.cmake import cmake_module_dir from prik.parsers.fortran import FortranParseError import prik.cli as prik_cli from prik.parsers.fortran import cli as fortran_parser_cli @@ -809,3 +810,38 @@ def test_probe_mapping_report_rejects_preprocessing_options(option): def test_probe_expressions_are_fortran_only(): with pytest.raises(ValueError, match="--expr is supported only for --language fortran"): prik_cli._probe_output(_probe_args(language="c", expressions=["kind(1.0)"])) + + +def _cli_path(command: str) -> Path: + """Return the single path one path-printing command prints.""" + printed = subprocess.run( + [sys.executable, "-m", "prik", command], + capture_output=True, + text=True, + check=True, + ) + return Path(printed.stdout.strip()) + + +def test_cmake_dir_prints_the_packaged_cmake_module_directory(): + """A CMake build substitutes this into PRIK_DIR or CMAKE_MODULE_PATH.""" + module_dir = _cli_path("cmake-dir") + + assert module_dir == cmake_module_dir() + assert (module_dir / "UsePRIK.cmake").is_file() + assert (module_dir / "PRIKConfig.cmake").is_file() + + +def test_install_dir_prints_a_prefix_that_holds_installed_data_or_reports_none(): + """The prefix is only useful when PRIK's data is under it, so nothing else is printed.""" + printed = subprocess.run( + [sys.executable, "-m", "prik", "install-dir"], + capture_output=True, + text=True, + ) + + if printed.returncode == 0: + assert (Path(printed.stdout.strip()) / "share" / "prik").is_dir() + else: + assert "not installed" in printed.stderr + assert not printed.stdout.strip() From c62556f7c4487c49bb5cbdcd7a51a5d8cd0641fe Mon Sep 17 00:00:00 2001 From: said Date: Sun, 13 Sep 2026 08:29:50 +0100 Subject: [PATCH 10/15] codex: search the user install base for PRIK's data files `pip install --user` writes data files under the user base rather than under sys.prefix, so install_dir() missed that installation entirely and reported PRIK as uninstalled. data_roots() now searches the user base as well, and _helper_path() reaches a user-installed share/prik/cmake through the same list. The root is only searched while the interpreter would import from the user site at all. A virtual environment, -s, and -I all switch that off, and the data there then belongs to an installation this interpreter cannot use, so naming its prefix would point CMake at a different PRIK than the one answering. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KMz3kPhNruZUmNkDAMT9KJ --- prik/installation.py | 27 ++++++--- .../pipeline/test_installation_paths.py | 57 +++++++++++++++++++ 2 files changed, 75 insertions(+), 9 deletions(-) create mode 100644 tests/fortran/infrastructure/building/pipeline/test_installation_paths.py diff --git a/prik/installation.py b/prik/installation.py index e59db7453..8945c5baf 100644 --- a/prik/installation.py +++ b/prik/installation.py @@ -8,20 +8,29 @@ from __future__ import annotations from pathlib import Path +import site import sys import sysconfig def data_roots() -> tuple[Path, ...]: - """Return the prefixes an installation's ``share/prik`` data can sit under.""" - data_root = sysconfig.get_path("data") - roots = [Path(data_root)] if data_root else [] - roots.append(Path(sys.prefix)) - unique: list[Path] = [] - for root in roots: - if root not in unique: - unique.append(root) - return tuple(unique) + """Return the prefixes an installation's ``share/prik`` data can sit under. + + ``pip install --user`` writes data files under the user base instead of + under ``sys.prefix``, so that root belongs here too -- but only while this + interpreter would import from the user site at all. A virtual environment, + ``-s``, and ``-I`` all switch it off, and the files there then belong to an + installation this interpreter cannot use. + """ + candidates = [sysconfig.get_path("data"), sys.prefix] + if site.ENABLE_USER_SITE: + candidates.append(site.getuserbase()) + roots: list[Path] = [] + for candidate in candidates: + root = Path(candidate) if candidate else None + if root is not None and root not in roots: + roots.append(root) + return tuple(roots) def install_dir() -> Path: diff --git a/tests/fortran/infrastructure/building/pipeline/test_installation_paths.py b/tests/fortran/infrastructure/building/pipeline/test_installation_paths.py new file mode 100644 index 000000000..b71beb364 --- /dev/null +++ b/tests/fortran/infrastructure/building/pipeline/test_installation_paths.py @@ -0,0 +1,57 @@ +"""Where PRIK looks for the data files an installation carries. + +A build integration asks PRIK for an installation prefix and hands it straight +to another tool, so the searched roots have to match what an install actually +wrote -- including a ``pip install --user``, whose data files land under the +user base rather than under ``sys.prefix``. +""" + +from pathlib import Path +import site +import sys +import sysconfig + +import pytest + +from prik.installation import data_roots, install_dir + + +@pytest.fixture +def only_empty_prefixes(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + """Point every non-user root at a prefix that installs nothing.""" + empty = tmp_path / "empty prefix" + empty.mkdir() + monkeypatch.setattr(sysconfig, "get_path", lambda name, *arguments, **options: str(empty)) + monkeypatch.setattr(sys, "prefix", str(empty)) + return empty + + +def test_data_roots_include_the_user_base_of_a_user_install(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(site, "ENABLE_USER_SITE", True) + + assert Path(site.getuserbase()) in data_roots() + + +def test_data_roots_drop_the_user_base_when_the_user_site_is_disabled(monkeypatch: pytest.MonkeyPatch) -> None: + """A venv, -s, and -I cannot import from the user site, so its data is not PRIK's.""" + monkeypatch.setattr(site, "ENABLE_USER_SITE", False) + + assert Path(site.getuserbase()) not in data_roots() + + +@pytest.mark.usefixtures("only_empty_prefixes") +def test_install_dir_reports_the_user_base_of_a_user_install(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + user_base = tmp_path / "user base" + (user_base / "share" / "prik" / "cmake").mkdir(parents=True) + monkeypatch.setattr(site, "ENABLE_USER_SITE", True) + monkeypatch.setattr(site, "getuserbase", lambda: str(user_base)) + + assert install_dir() == user_base + + +@pytest.mark.usefixtures("only_empty_prefixes") +def test_install_dir_reports_no_prefix_when_nothing_is_installed(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(site, "ENABLE_USER_SITE", False) + + with pytest.raises(FileNotFoundError, match="not installed"): + install_dir() From f51a68249e00a4545c33f7d163c0ccad0d9bc961 Mon Sep 17 00:00:00 2001 From: said Date: Sun, 13 Sep 2026 08:29:50 +0100 Subject: [PATCH 11/15] codex: add a runnable CMake discovery example examples/cmake/ builds one Fortran module through every route a CMake project can use to reach PRIK: scikit-build-core's cmake.module entry point, CMAKE_MODULE_PATH, PRIK_DIR, and an installation prefix. One CMakeLists.txt serves all four; only the line that loads PRIK changes, selected by the PRIK_DISCOVERY cache option. check_discovery_routes.sh builds and calls the extension once per route and reports a route whose prerequisite is missing rather than failing it, so the script is also how a contributor checks a route in their own environment. The CMake guide keeps the route documentation and gains the scikit-build-core build-requirement snippet, rather than repeating the routes in a tutorial page. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KMz3kPhNruZUmNkDAMT9KJ --- CHANGELOG.md | 5 + docs/user/guide/cmake.md | 24 +++- examples/cmake/CMakeLists.txt | 32 +++++ examples/cmake/README.md | 83 ++++++++++++ examples/cmake/check_discovery_routes.sh | 156 +++++++++++++++++++++++ examples/cmake/kernel.f90 | 26 ++++ examples/cmake/pyproject.toml | 13 ++ 7 files changed, 336 insertions(+), 3 deletions(-) create mode 100644 examples/cmake/CMakeLists.txt create mode 100644 examples/cmake/README.md create mode 100755 examples/cmake/check_discovery_routes.sh create mode 100644 examples/cmake/kernel.f90 create mode 100644 examples/cmake/pyproject.toml diff --git a/CHANGELOG.md b/CHANGELOG.md index 768304438..18b51ad4c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -71,6 +71,11 @@ release tags add a leading `v` to the package version. letting the CMake integration tests deselect themselves, and report a missing Ninja as a warning because those tests then use the Makefile generator. +- Added the runnable `examples/cmake/` project, which builds one Fortran module + through every CMake discovery route -- scikit-build-core's entry point, + `CMAKE_MODULE_PATH`, `PRIK_DIR`, and an installation prefix -- with a script + that runs each route and calls the built extension. + - Array handles support allocatable and pointer arguments, results, module variables, derived fields, optional arguments, and matching ordinary-array parameters. Numeric and character arrays accept supported forward and diff --git a/docs/user/guide/cmake.md b/docs/user/guide/cmake.md index f3a2a285f..819f9d2d4 100644 --- a/docs/user/guide/cmake.md +++ b/docs/user/guide/cmake.md @@ -88,14 +88,28 @@ cmake --build build ### Other ways to find the helper -With [scikit-build-core](https://scikit-build-core.readthedocs.io/), PRIK's -packaged CMake directory reaches `CMAKE_MODULE_PATH` through its `cmake.module` -entry point, so the project needs only the include: +With [scikit-build-core](https://scikit-build-core.readthedocs.io/), name PRIK +as a build requirement: + +```toml +[build-system] +requires = ["scikit-build-core>=0.10", "prik"] +build-backend = "scikit_build_core.build" +``` + +The backend reads PRIK's `cmake.module` entry point and puts its packaged CMake +directory on `CMAKE_MODULE_PATH`, so the project needs only the include: ```cmake include(UsePRIK) ``` +Building the wheel is then one command: + +```bash +python3 -m pip wheel . --no-deps --wheel-dir dist +``` + PRIK also packages `PRIKConfig.cmake` beside the helper, so any project can load it as a CMake package instead: @@ -130,6 +144,10 @@ naming a prefix; `cmake-dir` always answers. `find_package(PRIK CONFIG REQUIRED)` provides exactly what `include(UsePRIK)` provides. +[`examples/cmake/`](../../../examples/cmake/README.md) is a runnable project +that builds the same module through every route, with a script that checks each +one in turn. + `prik_add_module()` also accepts `SOURCES` for source-first input, `CONTRACT` with `FORTRAN_SOURCES` or `C_SOURCES` for an authored semantic `.pyi`, `INCLUDE_DIRS`, `MODULE_DIRS`, native and generated-source compile flag groups, diff --git a/examples/cmake/CMakeLists.txt b/examples/cmake/CMakeLists.txt new file mode 100644 index 000000000..a65e0685a --- /dev/null +++ b/examples/cmake/CMakeLists.txt @@ -0,0 +1,32 @@ +cmake_minimum_required(VERSION 3.21) + +project(prik_cmake_example LANGUAGES C Fortran) + +find_package( + Python + COMPONENTS Interpreter Development.Module + REQUIRED +) + +set(PRIK_DISCOVERY "include" CACHE STRING "How this project loads PRIK's CMake modules") +set_property(CACHE PRIK_DISCOVERY PROPERTY STRINGS include find-package) + +if(PRIK_DISCOVERY STREQUAL "find-package") + # Needs PRIK's CMake package on a search path: PRIK_DIR set to + # `prik cmake-dir`, or CMAKE_PREFIX_PATH set to `prik install-dir`. + find_package(PRIK CONFIG REQUIRED) +else() + # scikit-build-core adds PRIK's packaged module directory to + # CMAKE_MODULE_PATH through its cmake.module entry point, so this include + # needs no lookup. A plain configure gets there by setting + # CMAKE_MODULE_PATH to `prik cmake-dir`. + include(UsePRIK) +endif() + +prik_add_module( + heat + FORTRAN_SOURCES + kernel.f90 +) + +install(TARGETS heat DESTINATION .) diff --git a/examples/cmake/README.md b/examples/cmake/README.md new file mode 100644 index 000000000..5bc83f872 --- /dev/null +++ b/examples/cmake/README.md @@ -0,0 +1,83 @@ +# Build a PRIK Extension from CMake + +This example builds one small Fortran module into an importable extension once +per route a CMake project can use to find PRIK's packaged helper. + +The [CMake builds guide](../../docs/user/guide/cmake.md) documents the routes +and the full `prik_add_module()` surface. + +## Project layout + +| File | Role | +| --- | --- | +| [`kernel.f90`](kernel.f90) | Fortran module with one diffusion step and one reduction | +| [`CMakeLists.txt`](CMakeLists.txt) | One `prik_add_module()` call; `PRIK_DISCOVERY` selects `include` or `find-package` | +| [`pyproject.toml`](pyproject.toml) | The same project as a scikit-build-core wheel | +| [`check_discovery_routes.sh`](check_discovery_routes.sh) | Builds and calls the extension once per route | + +| Route | What the project calls | What you pass | +| --- | --- | --- | +| `module-path` | `include(UsePRIK)` | `-DCMAKE_MODULE_PATH="$(prik cmake-dir)"` | +| `find-package-dir` | `find_package(PRIK CONFIG REQUIRED)` | `-DPRIK_DIR="$(prik cmake-dir)"` | +| `install-prefix` | `find_package(PRIK CONFIG REQUIRED)` | `-DCMAKE_PREFIX_PATH="$(prik install-dir)"` | +| `scikit-build-core` | `include(UsePRIK)` | nothing: the build backend reads PRIK's `cmake.module` entry point | + +## Requirements + +CMake 3.21 or newer, a Fortran and C compiler, Python development headers, and +NumPy. On Ubuntu: + +```console +sudo apt-get update +sudo apt-get install --yes build-essential gfortran cmake ninja-build python3-dev +python3 -m pip install "numpy>=2.1" +``` + +Run the remaining commands from the repository root. + +## Quick start + +```bash +PYTHONPATH=. examples/cmake/check_discovery_routes.sh +``` + +`PYTHONPATH` lets the interpreter CMake drives import PRIK from this checkout; +drop it when PRIK is installed. Each route prints its own line, and a route +whose prerequisite is missing is reported rather than failed: + +```console + diffuse([0.0, 1.0, 0.0], 0.25) -> [0.0, 0.5, 0.0] +OK module-path + diffuse([0.0, 1.0, 0.0], 0.25) -> [0.0, 0.5, 0.0] +OK find-package-dir +SKIPPED install-prefix PRIK is not installed; run this route against an installed PRIK + installed wheel: diffuse -> [0.0, 0.5, 0.0] +OK scikit-build-core +skipped: install-prefix +``` + +Name routes to run a subset, and set `PRIK_EXAMPLE_PYTHON` to choose the +interpreter that answers for PRIK and builds the extension: + +```bash +PYTHONPATH=. examples/cmake/check_discovery_routes.sh module-path find-package-dir +PRIK_EXAMPLE_PYTHON=/path/to/venv/bin/python examples/cmake/check_discovery_routes.sh install-prefix +``` + +## Calling the extension + +The built extension is `heat`, and the Fortran module `kernel` is a namespace +inside it: + +```python +import numpy + +import heat + +values = numpy.array([0.0, 1.0, 0.0]) +stepped = heat.kernel.diffuse(values, numpy.float64(0.25)) # [0.0, 0.5, 0.0] +conserved = heat.kernel.total(values) # 1.0 +``` + +Scalar arguments take NumPy scalars, which is PRIK's ordinary calling +convention rather than anything specific to CMake builds. diff --git a/examples/cmake/check_discovery_routes.sh b/examples/cmake/check_discovery_routes.sh new file mode 100755 index 000000000..5fe663299 --- /dev/null +++ b/examples/cmake/check_discovery_routes.sh @@ -0,0 +1,156 @@ +#!/usr/bin/env bash +# Build this example once per PRIK CMake discovery route, then call the result. +# +# Run it from the repository root. Name routes as arguments to run a subset: +# examples/cmake/check_discovery_routes.sh module-path find-package-dir +set -u + +EXAMPLE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PYTHON="${PRIK_EXAMPLE_PYTHON:-python3}" +WORKSPACE="$(mktemp -d)" +trap 'rm -rf "$WORKSPACE"' EXIT + +ALL_ROUTES=(module-path find-package-dir install-prefix scikit-build-core) +ROUTES=("$@") +if [ "${#ROUTES[@]}" -eq 0 ]; then + ROUTES=("${ALL_ROUTES[@]}") +fi + +failures=() +skips=() + +prik() { + "$PYTHON" -m prik "$@" +} + +report() { # outcome route detail + printf '%-16s %-20s %s\n' "$1" "$2" "${3:-}" +} + +call_built_module() { # build_dir + local artifact module_dir + artifact="$(find "$1" -name 'heat*.so' -print -quit)" + if [ -z "$artifact" ]; then + echo "no built extension under $1" >&2 + return 1 + fi + module_dir="$(dirname "$artifact")" + PYTHONPATH="$module_dir${PYTHONPATH:+:$PYTHONPATH}" "$PYTHON" - <<'PYTHON' +import numpy + +import heat + +values = numpy.array([0.0, 1.0, 0.0]) +stepped = heat.kernel.diffuse(values, numpy.float64(0.25)) +assert stepped.tolist() == [0.0, 0.5, 0.0], stepped +assert heat.kernel.total(values) == 1.0 +print(f" diffuse({values.tolist()}, 0.25) -> {stepped.tolist()}") +PYTHON +} + +configure_and_call() { # route build_dir cmake_argument... + local route="$1" build="$2" + shift 2 + if ! cmake -S "$EXAMPLE_DIR" -B "$build" -DPython_EXECUTABLE="$("$PYTHON" -c 'import sys; print(sys.executable)')" \ + "$@" >"$build.log" 2>&1 || + ! cmake --build "$build" -j2 >>"$build.log" 2>&1; then + tail -20 "$build.log" >&2 + failures+=("$route") + report FAILED "$route" "see $build.log" + return 1 + fi + call_built_module "$build" || { + failures+=("$route") + report FAILED "$route" "built extension did not answer" + return 1 + } + report OK "$route" +} + +route_module_path() { + local module_dir + module_dir="$(prik cmake-dir)" || { + skips+=("module-path") + report SKIPPED module-path "prik cmake-dir is unavailable" + return 0 + } + configure_and_call module-path "$WORKSPACE/module-path" \ + -DPRIK_DISCOVERY=include -DCMAKE_MODULE_PATH="$module_dir" +} + +route_find_package_dir() { + local module_dir + module_dir="$(prik cmake-dir)" || { + skips+=("find-package-dir") + report SKIPPED find-package-dir "prik cmake-dir is unavailable" + return 0 + } + configure_and_call find-package-dir "$WORKSPACE/find-package-dir" \ + -DPRIK_DISCOVERY=find-package -DPRIK_DIR="$module_dir" +} + +route_install_prefix() { + local prefix + if ! prefix="$(prik install-dir 2>/dev/null)"; then + skips+=("install-prefix") + report SKIPPED install-prefix "PRIK is not installed; run this route against an installed PRIK" + return 0 + fi + configure_and_call install-prefix "$WORKSPACE/install-prefix" \ + -DPRIK_DISCOVERY=find-package -DCMAKE_PREFIX_PATH="$prefix" +} + +route_scikit_build_core() { + local environment="$WORKSPACE/skbc" wheel + "$PYTHON" -m venv "$environment" >/dev/null 2>&1 + if ! "$environment/bin/python" -m pip install --quiet scikit-build-core "$EXAMPLE_DIR/../.." \ + >"$WORKSPACE/skbc-install.log" 2>&1; then + tail -5 "$WORKSPACE/skbc-install.log" >&2 + skips+=("scikit-build-core") + report SKIPPED scikit-build-core "cannot install scikit-build-core and PRIK" + return 0 + fi + if ! "$environment/bin/python" -m pip wheel --no-build-isolation --no-deps \ + --wheel-dir "$WORKSPACE/skbc-wheel" "$EXAMPLE_DIR" \ + >"$WORKSPACE/skbc-build.log" 2>&1; then + tail -20 "$WORKSPACE/skbc-build.log" >&2 + failures+=("scikit-build-core") + report FAILED scikit-build-core "see $WORKSPACE/skbc-build.log" + return 1 + fi + wheel="$(find "$WORKSPACE/skbc-wheel" -name 'prik_cmake_example-*.whl' -print -quit)" + "$environment/bin/python" -m pip install --quiet "$wheel" >/dev/null 2>&1 + if ! "$environment/bin/python" -c ' +import numpy, heat +values = numpy.array([0.0, 1.0, 0.0]) +stepped = heat.kernel.diffuse(values, numpy.float64(0.25)) +assert stepped.tolist() == [0.0, 0.5, 0.0], stepped +print(f" installed wheel: diffuse -> {stepped.tolist()}") +'; then + failures+=("scikit-build-core") + report FAILED scikit-build-core "the built wheel did not answer" + return 1 + fi + report OK scikit-build-core +} + +for route in "${ROUTES[@]}"; do + case "$route" in + module-path) route_module_path ;; + find-package-dir) route_find_package_dir ;; + install-prefix) route_install_prefix ;; + scikit-build-core) route_scikit_build_core ;; + *) + echo "unknown route: $route (known: ${ALL_ROUTES[*]})" >&2 + exit 2 + ;; + esac +done + +if [ "${#skips[@]}" -gt 0 ]; then + echo "skipped: ${skips[*]}" +fi +if [ "${#failures[@]}" -gt 0 ]; then + echo "failed: ${failures[*]}" >&2 + exit 1 +fi diff --git a/examples/cmake/kernel.f90 b/examples/cmake/kernel.f90 new file mode 100644 index 000000000..1749dbad2 --- /dev/null +++ b/examples/cmake/kernel.f90 @@ -0,0 +1,26 @@ +module kernel + implicit none +contains + + pure function diffuse(values, rate) result(output) + !! One explicit diffusion step across an interior with fixed boundaries. + real(8), intent(in) :: values(:) + real(8), intent(in) :: rate + real(8) :: output(size(values)) + integer :: cell + + output = values + do cell = 2, size(values) - 1 + output(cell) = values(cell) + rate * (values(cell - 1) - 2 * values(cell) + values(cell + 1)) + end do + end function diffuse + + pure function total(values) result(amount) + !! The quantity a caller can compare before and after any number of steps. + real(8), intent(in) :: values(:) + real(8) :: amount + + amount = sum(values) + end function total + +end module kernel diff --git a/examples/cmake/pyproject.toml b/examples/cmake/pyproject.toml new file mode 100644 index 000000000..726041e91 --- /dev/null +++ b/examples/cmake/pyproject.toml @@ -0,0 +1,13 @@ +[build-system] +requires = ["scikit-build-core>=0.10", "prik"] +build-backend = "scikit_build_core.build" + +[project] +name = "prik-cmake-example" +version = "0.1.0" +description = "A PRIK extension built by scikit-build-core through UsePRIK.cmake" +requires-python = ">=3.10" +dependencies = ["numpy>=2.1"] + +[tool.scikit-build] +cmake.version = ">=3.21" From 8e0bb73b33f8e4d0c37d0ef9360a4b677bded2c2 Mon Sep 17 00:00:00 2001 From: said Date: Sun, 13 Sep 2026 08:50:20 +0100 Subject: [PATCH 12/15] codex: derive install-dir from PRIK's own installation record install_dir() searched candidate prefixes and returned the first holding share/prik, so a global installation could answer for a `pip install --user` one: -DCMAKE_PREFIX_PATH="$(prik install-dir)" would then point CMake at a different PRIK than the one that ran. cmake-dir never had that problem, because it starts from the imported package. The prefix now comes from the running distribution's recorded data file, located through importlib.metadata and resolved to the prefix above share/prik/cmake. Both commands are exact about the same installation. A distribution answers only when it provides the package running here, either by recording it or by being an editable install over the tree containing it. An editable install records no data files at all, so it reports that rather than a prefix, and data_roots() remains only the last-resort search that finds a packaged UsePRIK.cmake. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KMz3kPhNruZUmNkDAMT9KJ --- docs/user/guide/cmake.md | 12 +- docs/user/reference/cli-commands.md | 2 +- prik/installation.py | 69 +++++++++-- .../pipeline/test_installation_paths.py | 114 ++++++++++++++---- .../cli/pipeline/test_stage_dispatch.py | 2 +- 5 files changed, 160 insertions(+), 39 deletions(-) diff --git a/docs/user/guide/cmake.md b/docs/user/guide/cmake.md index 819f9d2d4..9dab3f436 100644 --- a/docs/user/guide/cmake.md +++ b/docs/user/guide/cmake.md @@ -130,16 +130,18 @@ module directory, which is where `PRIKConfig.cmake` lives: cmake -S . -B build -DPRIK_DIR="$(prik cmake-dir)" ``` -`prik install-dir` prints the prefix PRIK is installed under, which carries the -same modules in `share/prik/cmake` and also resolves everything else installed -there: +`prik install-dir` prints the prefix this PRIK's own installation wrote its +data files under, which carries the same modules in `share/prik/cmake` and also +resolves everything else installed there: ```bash cmake -S . -B build -DCMAKE_PREFIX_PATH="$(prik install-dir)" ``` -A source checkout installs nothing, so `install-dir` reports that instead of -naming a prefix; `cmake-dir` always answers. +The prefix comes from the running installation's own record, so a second PRIK +installed elsewhere never answers for it. A source checkout installs nothing, +and an editable install writes no data files, so `install-dir` reports that +instead of naming a prefix; `cmake-dir` always answers. `find_package(PRIK CONFIG REQUIRED)` provides exactly what `include(UsePRIK)` provides. diff --git a/docs/user/reference/cli-commands.md b/docs/user/reference/cli-commands.md index 3493e597c..e6ea8fff9 100644 --- a/docs/user/reference/cli-commands.md +++ b/docs/user/reference/cli-commands.md @@ -304,7 +304,7 @@ cmake -S . -B build -DCMAKE_PREFIX_PATH="$(prik install-dir)" | Command | Prints | | --- | --- | | `cmake-dir` | The directory holding `UsePRIK.cmake` and `PRIKConfig.cmake`. Use it as `PRIK_DIR`, or add it to `CMAKE_MODULE_PATH` for `include(UsePRIK)`. | -| `install-dir` | The prefix PRIK's data files are installed under, which holds the same CMake modules in `share/prik/cmake`. Use it in `CMAKE_PREFIX_PATH`. A source checkout installs nothing, so the command reports that rather than naming a prefix. | +| `install-dir` | The prefix this PRIK's own installation wrote its data files under, which holds the same CMake modules in `share/prik/cmake`. Use it in `CMAKE_PREFIX_PATH`. It comes from that installation's record, so another PRIK installed elsewhere never answers for it. A source checkout installs nothing and an editable install writes no data files, so the command reports that rather than naming a prefix. | Both make `find_package(PRIK CONFIG REQUIRED)` resolve. See the [CMake builds guide](../guide/cmake.md) for the project side. diff --git a/prik/installation.py b/prik/installation.py index 8945c5baf..6921feae2 100644 --- a/prik/installation.py +++ b/prik/installation.py @@ -7,15 +7,26 @@ from __future__ import annotations +from importlib import metadata +import json from pathlib import Path import site import sys import sysconfig +from urllib.parse import urlparse +from urllib.request import url2pathname + +_PACKAGE_DIR = Path(__file__).resolve().parent +_RECORDED_CONFIG = ("share", "prik", "cmake", "PRIKConfig.cmake") def data_roots() -> tuple[Path, ...]: """Return the prefixes an installation's ``share/prik`` data can sit under. + These are candidates to search for a file, not an identity. A prefix that + holds PRIK data may belong to another installation entirely, which is why + `install_dir` reads its own installation's record instead. + ``pip install --user`` writes data files under the user base instead of under ``sys.prefix``, so that root belongs here too -- but only while this interpreter would import from the user site at all. A virtual environment, @@ -34,15 +45,53 @@ def data_roots() -> tuple[Path, ...]: def install_dir() -> Path: - """Return the prefix holding this environment's installed PRIK data files. + """Return the prefix this PRIK's own installation wrote its data files under. - A source checkout installs nothing, so there is no such prefix and none is - invented: an answer that named a directory without PRIK's data in it would - only fail later, inside whichever tool consumed it. + The prefix comes from the running distribution's recorded files rather than + from searching candidate roots, so a second PRIK installed under another + prefix -- a global one beside a ``pip install --user`` -- can never answer + for this one. Nothing is invented when there is no such installation: a + prefix holding no data of ours would only fail later, inside whichever tool + consumed it. """ - roots = data_roots() - for root in roots: - if (root / "share" / "prik").is_dir(): - return root - searched = ", ".join(str(root) for root in roots) - raise FileNotFoundError(f"PRIK is not installed with data files; no share/prik under: {searched}") + distribution = _running_distribution() + for recorded in distribution.files or (): + if recorded.parts[-len(_RECORDED_CONFIG) :] != _RECORDED_CONFIG: + continue + located = Path(distribution.locate_file(recorded)).resolve() + if located.is_file(): + return located.parents[len(_RECORDED_CONFIG) - 1] + raise FileNotFoundError( + "this PRIK installation recorded no share/prik/cmake data files; an editable install writes none" + ) + + +def _running_distribution() -> metadata.Distribution: + """Return the installed distribution that provides the PRIK running here.""" + try: + distribution = metadata.distribution("prik") + except metadata.PackageNotFoundError as exc: + raise FileNotFoundError("PRIK is not installed: no prik distribution metadata is importable") from exc + if Path(distribution.locate_file("prik")).resolve() == _PACKAGE_DIR: + return distribution + editable_source = _editable_source(distribution) + if editable_source is not None and _PACKAGE_DIR.is_relative_to(editable_source): + return distribution + raise FileNotFoundError(f"the installed prik distribution does not provide the PRIK running from {_PACKAGE_DIR}") + + +def _editable_source(distribution: metadata.Distribution) -> Path | None: + """Return the tree an editable installation points at, when it is one.""" + recorded = distribution.read_text("direct_url.json") + if not recorded: + return None + try: + direct_url = json.loads(recorded) + except json.JSONDecodeError: + return None + if not direct_url.get("dir_info", {}).get("editable"): + return None + url = urlparse(str(direct_url.get("url", ""))) + if url.scheme != "file": + return None + return Path(url2pathname(url.path)).resolve() diff --git a/tests/fortran/infrastructure/building/pipeline/test_installation_paths.py b/tests/fortran/infrastructure/building/pipeline/test_installation_paths.py index b71beb364..87ecae83f 100644 --- a/tests/fortran/infrastructure/building/pipeline/test_installation_paths.py +++ b/tests/fortran/infrastructure/building/pipeline/test_installation_paths.py @@ -1,11 +1,14 @@ -"""Where PRIK looks for the data files an installation carries. +"""Which installation answers for the PRIK that is running. -A build integration asks PRIK for an installation prefix and hands it straight -to another tool, so the searched roots have to match what an install actually -wrote -- including a ``pip install --user``, whose data files land under the -user base rather than under ``sys.prefix``. +A build integration hands the reported prefix straight to another tool, so it +has to belong to this PRIK. Two installations can hold ``share/prik`` at once +-- a global one beside a ``pip install --user`` -- and the prefix that answers +must be the one whose record covers the running package, not whichever root is +searched first. """ +from importlib import metadata +import json from pathlib import Path import site import sys @@ -13,17 +16,53 @@ import pytest +import prik from prik.installation import data_roots, install_dir +PACKAGE_DIR = Path(prik.__file__).resolve().parent +RECORDED_CONFIG = "../../../share/prik/cmake/PRIKConfig.cmake" + + +class _FakeDistribution: + """Stand in for the metadata one installed PRIK distribution records.""" + + def __init__(self, *, package_dir: Path, prefix: Path | None = None, editable_source: Path | None = None) -> None: + self._package_dir = package_dir + self._prefix = prefix + self._editable_source = editable_source + + @property + def files(self) -> list[metadata.PackagePath]: + return [metadata.PackagePath(RECORDED_CONFIG)] if self._prefix is not None else [] + + def locate_file(self, path: object) -> Path: + if str(path) == "prik": + return self._package_dir + assert self._prefix is not None + return self._prefix / "lib" / "python3" / "site-packages" / str(path) + + def read_text(self, name: str) -> str | None: + if name != "direct_url.json" or self._editable_source is None: + return None + return json.dumps({"dir_info": {"editable": True}, "url": self._editable_source.as_uri()}) + + +def _installed_prefix(root: Path) -> Path: + """Write the data files and package directory a wheel installs under one prefix.""" + (root / "share" / "prik" / "cmake").mkdir(parents=True) + (root / "share" / "prik" / "cmake" / "PRIKConfig.cmake").write_text("", encoding="utf-8") + (root / "lib" / "python3" / "site-packages").mkdir(parents=True) + return root + + @pytest.fixture -def only_empty_prefixes(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: - """Point every non-user root at a prefix that installs nothing.""" - empty = tmp_path / "empty prefix" - empty.mkdir() - monkeypatch.setattr(sysconfig, "get_path", lambda name, *arguments, **options: str(empty)) - monkeypatch.setattr(sys, "prefix", str(empty)) - return empty +def other_installation(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + """Put a complete, unrelated PRIK installation on every searchable root.""" + other = _installed_prefix(tmp_path / "other installation") + monkeypatch.setattr(sysconfig, "get_path", lambda name, *arguments, **options: str(other)) + monkeypatch.setattr(sys, "prefix", str(other)) + return other def test_data_roots_include_the_user_base_of_a_user_install(monkeypatch: pytest.MonkeyPatch) -> None: @@ -39,19 +78,50 @@ def test_data_roots_drop_the_user_base_when_the_user_site_is_disabled(monkeypatc assert Path(site.getuserbase()) not in data_roots() -@pytest.mark.usefixtures("only_empty_prefixes") -def test_install_dir_reports_the_user_base_of_a_user_install(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - user_base = tmp_path / "user base" - (user_base / "share" / "prik" / "cmake").mkdir(parents=True) - monkeypatch.setattr(site, "ENABLE_USER_SITE", True) - monkeypatch.setattr(site, "getuserbase", lambda: str(user_base)) +@pytest.mark.usefixtures("other_installation") +def test_install_dir_reports_the_prefix_its_own_installation_recorded( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A second PRIK under a searchable prefix must never answer for this one.""" + prefix = _installed_prefix(tmp_path / "user base") + monkeypatch.setattr( + metadata, "distribution", lambda name: _FakeDistribution(package_dir=PACKAGE_DIR, prefix=prefix) + ) - assert install_dir() == user_base + assert install_dir() == prefix -@pytest.mark.usefixtures("only_empty_prefixes") -def test_install_dir_reports_no_prefix_when_nothing_is_installed(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(site, "ENABLE_USER_SITE", False) +def test_install_dir_refuses_an_installation_of_another_package_copy( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + prefix = _installed_prefix(tmp_path / "other prefix") + elsewhere = prefix / "lib" / "python3" / "site-packages" / "prik" + monkeypatch.setattr(metadata, "distribution", lambda name: _FakeDistribution(package_dir=elsewhere, prefix=prefix)) + + with pytest.raises(FileNotFoundError, match="does not provide"): + install_dir() + + +def test_install_dir_accepts_an_editable_installation_of_the_running_tree( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """An editable install provides this package but records no data files.""" + elsewhere = tmp_path / "site-packages" / "prik" + monkeypatch.setattr( + metadata, + "distribution", + lambda name: _FakeDistribution(package_dir=elsewhere, editable_source=PACKAGE_DIR.parent), + ) + + with pytest.raises(FileNotFoundError, match="editable install writes none"): + install_dir() + + +def test_install_dir_reports_no_prefix_when_prik_is_not_installed(monkeypatch: pytest.MonkeyPatch) -> None: + def missing(name: str) -> metadata.Distribution: + raise metadata.PackageNotFoundError(name) + + monkeypatch.setattr(metadata, "distribution", missing) with pytest.raises(FileNotFoundError, match="not installed"): install_dir() diff --git a/tests/fortran/infrastructure/cli/pipeline/test_stage_dispatch.py b/tests/fortran/infrastructure/cli/pipeline/test_stage_dispatch.py index ce0ca4faf..04d0ef672 100644 --- a/tests/fortran/infrastructure/cli/pipeline/test_stage_dispatch.py +++ b/tests/fortran/infrastructure/cli/pipeline/test_stage_dispatch.py @@ -843,5 +843,5 @@ def test_install_dir_prints_a_prefix_that_holds_installed_data_or_reports_none() if printed.returncode == 0: assert (Path(printed.stdout.strip()) / "share" / "prik").is_dir() else: - assert "not installed" in printed.stderr + assert "prik" in printed.stderr.lower() assert not printed.stdout.strip() From ca7e1576eb662f05e4a49b6f72f9694da2f98777 Mon Sep 17 00:00:00 2001 From: said Date: Sun, 13 Sep 2026 11:14:52 +0100 Subject: [PATCH 13/15] codex: lead the CMake guide with find_package(PRIK) The section opened with the execute_process() lookup, which is the longest of the routes and the one fewest projects need. It now opens with find_package(PRIK CONFIG REQUIRED) configured by -DPRIK_DIR="$(prik cmake-dir)", and the remaining routes follow under "Discovery alternatives": an installation prefix, a module path, scikit-build-core, and asking a specific interpreter. That last route keeps its example and gains the reason to reach for it: the others answer for whichever prik the shell resolves, while it matches the interpreter CMake itself selected. Its command becomes `-m prik cmake-dir` rather than an inline import of cmake_module_dir(). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KMz3kPhNruZUmNkDAMT9KJ --- docs/user/guide/cmake.md | 103 +++++++++++++++++++-------------------- 1 file changed, 49 insertions(+), 54 deletions(-) diff --git a/docs/user/guide/cmake.md b/docs/user/guide/cmake.md index 9dab3f436..8f72a938b 100644 --- a/docs/user/guide/cmake.md +++ b/docs/user/guide/cmake.md @@ -43,8 +43,7 @@ newer. ## Existing CMake project -Install PRIK, make its `cmake` directory available through -`CMAKE_MODULE_PATH`, and include the packaged helper: +Install PRIK, load its CMake package, and declare the module: ```cmake cmake_minimum_required(VERSION 3.21) @@ -57,19 +56,7 @@ find_package( REQUIRED ) -# Ask PRIK's Python environment for its packaged CMake helper. -execute_process( - COMMAND "${Python_EXECUTABLE}" -c "from prik.cmake import cmake_module_dir; print(cmake_module_dir().as_posix())" - RESULT_VARIABLE PRIK_CMAKE_MODULE_RESULT - OUTPUT_VARIABLE PRIK_CMAKE_MODULE_DIR - ERROR_VARIABLE PRIK_CMAKE_MODULE_ERROR - OUTPUT_STRIP_TRAILING_WHITESPACE -) -if(NOT PRIK_CMAKE_MODULE_RESULT EQUAL 0) - message(FATAL_ERROR "Cannot locate UsePRIK.cmake: ${PRIK_CMAKE_MODULE_ERROR}") -endif() -list(APPEND CMAKE_MODULE_PATH "${PRIK_CMAKE_MODULE_DIR}") -include(UsePRIK) +find_package(PRIK CONFIG REQUIRED) prik_add_module( physics @@ -79,72 +66,80 @@ prik_add_module( ) ``` -Then configure and build the extension: +`prik cmake-dir` prints the directory holding PRIK's packaged CMake modules, +which is what `PRIK_DIR` names: ```bash -cmake -S . -B build +cmake -S . -B build -DPRIK_DIR="$(prik cmake-dir)" cmake --build build ``` -### Other ways to find the helper +### Discovery alternatives -With [scikit-build-core](https://scikit-build-core.readthedocs.io/), name PRIK -as a build requirement: +`find_package(PRIK CONFIG REQUIRED)` and `include(UsePRIK)` provide the same +helper, and the routes below differ only in how CMake reaches it. -```toml -[build-system] -requires = ["scikit-build-core>=0.10", "prik"] -build-backend = "scikit_build_core.build" +**An installation prefix.** `prik install-dir` prints the prefix this PRIK's +own installation wrote its data files under, which carries the same modules in +`share/prik/cmake` and also resolves everything else installed there: + +```bash +cmake -S . -B build -DCMAKE_PREFIX_PATH="$(prik install-dir)" ``` -The backend reads PRIK's `cmake.module` entry point and puts its packaged CMake -directory on `CMAKE_MODULE_PATH`, so the project needs only the include: +The prefix comes from the running installation's own record, so a second PRIK +installed elsewhere never answers for it. + +**A module path.** A project that prefers the module form replaces the +`find_package()` line with the include: ```cmake include(UsePRIK) ``` -Building the wheel is then one command: - ```bash -python3 -m pip wheel . --no-deps --wheel-dir dist +cmake -S . -B build -DCMAKE_MODULE_PATH="$(prik cmake-dir)" ``` -PRIK also packages `PRIKConfig.cmake` beside the helper, so any project can load -it as a CMake package instead: - -```cmake -find_package(PRIK CONFIG REQUIRED) +**scikit-build-core.** Name PRIK as a build requirement: -prik_add_module( - physics - FORTRAN_SOURCES - solver.f90 -) +```toml +[build-system] +requires = ["scikit-build-core>=0.10", "prik"] +build-backend = "scikit_build_core.build" ``` -Point CMake at PRIK when configuring. `prik cmake-dir` prints the packaged -module directory, which is where `PRIKConfig.cmake` lives: +The backend reads PRIK's `cmake.module` entry point and puts its packaged CMake +directory on `CMAKE_MODULE_PATH`, so `include(UsePRIK)` needs nothing on the +command line and building the wheel is one command: ```bash -cmake -S . -B build -DPRIK_DIR="$(prik cmake-dir)" +python3 -m pip wheel . --no-deps --wheel-dir dist ``` -`prik install-dir` prints the prefix this PRIK's own installation wrote its -data files under, which carries the same modules in `share/prik/cmake` and also -resolves everything else installed there: +**A specific Python interpreter.** The commands above answer for whichever +`prik` the shell resolves. When the build must instead match the interpreter +CMake itself selected -- several environments on one machine, or a +`Python_EXECUTABLE` the project pins -- ask that interpreter: -```bash -cmake -S . -B build -DCMAKE_PREFIX_PATH="$(prik install-dir)" +```cmake +execute_process( + COMMAND "${Python_EXECUTABLE}" -m prik cmake-dir + RESULT_VARIABLE PRIK_CMAKE_MODULE_RESULT + OUTPUT_VARIABLE PRIK_CMAKE_MODULE_DIR + ERROR_VARIABLE PRIK_CMAKE_MODULE_ERROR + OUTPUT_STRIP_TRAILING_WHITESPACE +) +if(NOT PRIK_CMAKE_MODULE_RESULT EQUAL 0) + message(FATAL_ERROR "Cannot locate UsePRIK.cmake: ${PRIK_CMAKE_MODULE_ERROR}") +endif() +list(APPEND CMAKE_MODULE_PATH "${PRIK_CMAKE_MODULE_DIR}") +include(UsePRIK) ``` -The prefix comes from the running installation's own record, so a second PRIK -installed elsewhere never answers for it. A source checkout installs nothing, -and an editable install writes no data files, so `install-dir` reports that -instead of naming a prefix; `cmake-dir` always answers. - -`find_package(PRIK CONFIG REQUIRED)` provides exactly what `include(UsePRIK)` -provides. +That form needs no `-D` argument at all. `prik generate --cmake` writes an +equivalent block into the standalone project it generates, which is why that +project configures with a plain `cmake -S . -B build`. [`examples/cmake/`](../../../examples/cmake/README.md) is a runnable project that builds the same module through every route, with a script that checks each From 7d6a3b0d32ff183f3d783f499b9f5c1be861c7e7 Mon Sep 17 00:00:00 2001 From: said Date: Sun, 13 Sep 2026 11:28:07 +0100 Subject: [PATCH 14/15] codex: restructure the CMake guide for scanning The page had four headings, one of which carried three quarters of it: the minimal project, every discovery route, the whole prik_add_module() surface as continuous prose, and the regeneration contract all sat under "Existing CMake project". It now moves from a minimal working project, to finding PRIK's CMake modules, to the common options, to languages and flags, to why rebuilds behave, to generating a standalone project. Both option surfaces become tables: the discovery routes, and every prik_add_module() keyword. The keyword table is checked against the helper's own cmake_parse_arguments sets, which is how MODULE_DIRS and LINK_OPTIONS gain a stated purpose rather than only a mention. The front matter keeps the two-phase contract and defers the fixed file list and dependency file to the new regeneration section, and the opening paragraph now links the CLI build guide it assumes. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KMz3kPhNruZUmNkDAMT9KJ --- docs/user/guide/cmake.md | 235 +++++++++++++++++++++------------------ 1 file changed, 125 insertions(+), 110 deletions(-) diff --git a/docs/user/guide/cmake.md b/docs/user/guide/cmake.md index 8f72a938b..d27ee7b15 100644 --- a/docs/user/guide/cmake.md +++ b/docs/user/guide/cmake.md @@ -11,37 +11,23 @@ publication: reviewed # CMake Builds Use CMake when its toolchain, dependency targets, and build scheduling should -own compilation and linking. PRIK still parses the native inputs, completes -wrapper policy, and generates the wrapper and any Fortran bridge sources. By -default, PRIK uses CMake's selected C or Fortran compiler for preprocessing, -source analysis, and ABI probes. CMake mode does not accept a separate -`--compiler` override because the analyzed and compiled toolchains must agree. - -The work splits across the two CMake phases: - -- **Configure** asks PRIK only for structure: the generated filenames, the link - driver, and any compiler ABI flags its profile requires. PRIK does not - preprocess, parse, complete policy, plan, or generate code here, so - configuring stays cheap however large the sources are. -- **Build** runs the full PRIK pipeline exactly once, writing the wrapper, the - header, the binding-support headers, and a dependency file. - -Because configuration never reads a source, the generated file list cannot -depend on what analysis finds. PRIK therefore names the optional compilation -units up front and always writes them: a module that needs no collision-adapter -unit still gets `_adapters.c`, and a Fortran module that needs no bridge -still gets `bind_c__wrapper.f90`. An unused unit holds a small -placeholder that defines no symbol and compiles without warnings. A semantic -edit then changes a file's *contents* rather than the set of files, so editing -a source never requires re-running `cmake` to configure. - -Transitive semantic inputs -- a nested C header, a Fortran `INCLUDE` file, an -imported contract -- reach CMake through the dependency file PRIK writes during -generation, so changing one reruns generation on the next build. This uses -`add_custom_command(DEPFILE)`, which is why the helper requires CMake 3.21 or -newer. - -## Existing CMake project +own compilation and linking, once you can already +[build an extension with the CLI](building-shared-library.md). PRIK still parses +the native inputs, completes wrapper policy -- argument contracts, results, and +the ABI plan -- and generates the wrapper and any Fortran bridge sources. It +analyzes with CMake's selected C or Fortran compiler, which is why CMake mode +accepts no separate `--compiler` override: the analyzed and compiled toolchains +must agree. + +The work splits across the two CMake phases. **Configure** asks PRIK only for +structure -- the generated filenames, the link driver, and any compiler ABI +flags its profile requires -- so it stays cheap however large the sources are. +**Build** runs the full PRIK pipeline exactly once, writing the wrapper, the +header, the binding-support headers, and a dependency file. What that split +means for editing sources is below, under +[regeneration and rebuilds](#regeneration-and-rebuilds). + +## Minimal existing project Install PRIK, load its CMake package, and declare the module: @@ -74,34 +60,28 @@ cmake -S . -B build -DPRIK_DIR="$(prik cmake-dir)" cmake --build build ``` -### Discovery alternatives +## Finding PRIK's CMake modules `find_package(PRIK CONFIG REQUIRED)` and `include(UsePRIK)` provide the same -helper, and the routes below differ only in how CMake reaches it. - -**An installation prefix.** `prik install-dir` prints the prefix this PRIK's -own installation wrote its data files under, which carries the same modules in -`share/prik/cmake` and also resolves everything else installed there: - -```bash -cmake -S . -B build -DCMAKE_PREFIX_PATH="$(prik install-dir)" -``` - -The prefix comes from the running installation's own record, so a second PRIK -installed elsewhere never answers for it. - -**A module path.** A project that prefers the module form replaces the -`find_package()` line with the include: - -```cmake -include(UsePRIK) -``` - -```bash -cmake -S . -B build -DCMAKE_MODULE_PATH="$(prik cmake-dir)" -``` - -**scikit-build-core.** Name PRIK as a build requirement: +helper, and these routes differ only in how CMake reaches it: + +| Route | The project calls | You pass | +| --- | --- | --- | +| Packaged directory | `find_package(PRIK CONFIG REQUIRED)` | `-DPRIK_DIR="$(prik cmake-dir)"` | +| Installation prefix | `find_package(PRIK CONFIG REQUIRED)` | `-DCMAKE_PREFIX_PATH="$(prik install-dir)"` | +| Module path | `include(UsePRIK)` | `-DCMAKE_MODULE_PATH="$(prik cmake-dir)"` | +| scikit-build-core | `include(UsePRIK)` | nothing; the backend reads PRIK's `cmake.module` entry point | + +`prik install-dir` prints the prefix this PRIK's own installation wrote its data +files under, which carries the same modules in `share/prik/cmake` and also +resolves everything else installed there. The prefix comes from that +installation's own record, so a second PRIK installed elsewhere never answers +for it. A source checkout installs nothing, and an editable install writes no +data files, so `install-dir` reports that instead of naming a prefix; +`cmake-dir` always answers. + +For a [scikit-build-core](https://scikit-build-core.readthedocs.io/) wheel, +name PRIK as a build requirement: ```toml [build-system] @@ -109,18 +89,18 @@ requires = ["scikit-build-core>=0.10", "prik"] build-backend = "scikit_build_core.build" ``` -The backend reads PRIK's `cmake.module` entry point and puts its packaged CMake -directory on `CMAKE_MODULE_PATH`, so `include(UsePRIK)` needs nothing on the -command line and building the wheel is one command: +The backend then puts PRIK's packaged CMake directory on `CMAKE_MODULE_PATH` +itself, so `include(UsePRIK)` needs nothing on the command line and building +the wheel is one command: ```bash python3 -m pip wheel . --no-deps --wheel-dir dist ``` -**A specific Python interpreter.** The commands above answer for whichever -`prik` the shell resolves. When the build must instead match the interpreter -CMake itself selected -- several environments on one machine, or a -`Python_EXECUTABLE` the project pins -- ask that interpreter: +Every route above answers for whichever `prik` the shell resolves. When the +build must instead match the interpreter CMake itself selected -- several +environments on one machine, or a `Python_EXECUTABLE` the project pins -- ask +that interpreter, which also needs no `-D` argument: ```cmake execute_process( @@ -137,19 +117,56 @@ list(APPEND CMAKE_MODULE_PATH "${PRIK_CMAKE_MODULE_DIR}") include(UsePRIK) ``` -That form needs no `-D` argument at all. `prik generate --cmake` writes an -equivalent block into the standalone project it generates, which is why that -project configures with a plain `cmake -S . -B build`. +`prik generate --cmake` writes an equivalent block into the standalone project +it generates, which is why that project configures with a plain +`cmake -S . -B build`. [`examples/cmake/`](../../../examples/cmake/README.md) is a runnable project that builds the same module through every route, with a script that checks each one in turn. -`prik_add_module()` also accepts `SOURCES` for source-first input, `CONTRACT` -with `FORTRAN_SOURCES` or `C_SOURCES` for an authored semantic `.pyi`, -`INCLUDE_DIRS`, `MODULE_DIRS`, native and generated-source compile flag groups, -`LINK_LIBRARIES`, `LIBRARY_DIRS`, `LINK_OPTIONS`, and additional -generation-only `PRIK_ARGS`. +## Common `prik_add_module()` options + +| Keyword | Purpose | +| --- | --- | +| `SOURCES` | Semantic input whose declarations PRIK wraps; also compiled, unless `NO_COMPILE_INPUT_SOURCES`. | +| `CONTRACT` | One authored semantic `.pyi` as the wrapped surface, instead of source declarations. | +| `FORTRAN_SOURCES`, `C_SOURCES` | Native implementation sources; either is also the semantic input when given alone. | +| `INCLUDE_DIRS`, `MODULE_DIRS` | Directories searched for headers and for prebuilt Fortran modules, by PRIK's analysis and by the compiled targets. | +| `LINK_LIBRARIES` | Link inputs: CMake targets, library names, file paths, or linker arguments. | +| `LIBRARY_DIRS` | Directories holding native libraries linked by name; also added to the extension's `BUILD_RPATH`. | +| `LINK_OPTIONS` | Additional options for the extension link. | +| `NATIVE_LANGUAGE`, `LINKER_LANGUAGE` | The contract's ABI language and the final CMake linker driver, stated independently. | +| `FORTRAN_FLAGS`, `C_FLAGS` | Compile options for user-owned native sources. | +| `WRAPPER_FORTRAN_FLAGS`, `WRAPPER_C_FLAGS` | Compile options for PRIK's generated sources. | +| `NO_COMPILE_INPUT_SOURCES` | `SOURCES` supplies only the public interface; the implementation arrives another way. | +| `NO_STANDARD_LOGICALS` | Disables PRIK's Intel/NVIDIA logical interoperability option. | +| `PRIK_ARGS` | Additional generation-only CLI options. | + +External dependencies remain CMake dependencies. For example, CMake can find +BLAS and pass its target to the PRIK extension: + +```cmake +find_package(BLAS REQUIRED) + +prik_add_module( + blas_example + FORTRAN_SOURCES blas_example.f90 + LINK_LIBRARIES BLAS::BLAS +) +``` + +The same form accepts normal project targets such as `native_math` and +`OpenMP::OpenMP_Fortran`. `LINK_LIBRARIES` reaches PRIK's private native object +target with its own syntax intact, so a linked target's compile and include +usage requirements apply to the native sources. It also keeps each entry's own +CMake meaning: a path to an object, archive, or shared library stays a file +path, a plain name stays a library name, and a `-Wl,...` entry stays a linker +argument in the position it was given. Raw library paths retain link behavior +but do not provide CMake usage requirements. + +## Languages, flags, and opaque natives + For a contract backed only by opaque native inputs, use `NATIVE_LANGUAGE` to state the contract ABI language and `LINKER_LANGUAGE` to state the final CMake linker driver independently: @@ -172,6 +189,12 @@ language must be enabled because every PRIK extension contains generated C binding code, and Fortran must be enabled whenever the module contributes Fortran sources. +Normal Fortran sources and targets carry their link-language requirements +through CMake. For a raw archive or shared library whose language is otherwise +opaque, add `LINKER_LANGUAGE Fortran`; PRIK records that requirement in its +plan and the extension uses CMake's Fortran linker driver. This is independent +of `NATIVE_LANGUAGE`, which controls semantic-contract interpretation. + C sources must use the lowercase `.c` suffix here. CMake compiles `.C` as C++, which would not match the C plan PRIK generates for the source, so `prik_add_module()` and `generate --cmake` reject that suffix instead of @@ -211,32 +234,8 @@ prik_add_module( ) ``` -The generated wrapper sources are custom-command outputs. Changing a semantic -source, contract, included C header, or Fortran `INCLUDE` file regenerates them -before CMake compiles the target, without a configure step. CMake recompiles -contract-first native implementations independently. A build with no changes -reruns neither generation nor compilation. - -External dependencies remain CMake dependencies. For example, CMake can find -BLAS and pass its target to the PRIK extension: - -```cmake -find_package(BLAS REQUIRED) - -prik_add_module( - blas_example - FORTRAN_SOURCES blas_example.f90 - LINK_LIBRARIES BLAS::BLAS -) -``` - -The same form accepts normal project targets such as `native_math` and -`OpenMP::OpenMP_Fortran`; they remain target-oriented CMake link inputs. -`LINK_LIBRARIES` reaches PRIK's private native object target with its own -syntax intact, so a linked target's compile and include usage requirements -apply to the native sources, and `debug`/`optimized` keywords and generator -expressions still select per configuration. Raw library paths retain link -behavior but do not provide CMake usage requirements. +`debug`/`optimized` keywords and generator expressions in `LINK_LIBRARIES` +still select usage requirements per configuration: ```cmake prik_add_module( @@ -247,11 +246,6 @@ prik_add_module( ) ``` -`LINK_LIBRARIES` keeps each entry's own CMake meaning: a path to an object, -archive, or shared library stays a file path, a plain name stays a library -name, and a `-Wl,...` entry stays a linker argument in the position it was -given. - `LIBRARY_DIRS` names directories that hold native libraries linked by name. PRIK gives them to `target_link_directories()` and appends them to the extension's `BUILD_RPATH`, so a shared native library outside the system @@ -268,11 +262,28 @@ prik_add_module( ) ``` -Normal Fortran sources and targets carry their link-language requirements -through CMake. For a raw archive or shared library whose language is otherwise -opaque, add `LINKER_LANGUAGE Fortran`; PRIK records that requirement in its -plan and the extension uses CMake's Fortran linker driver. This is independent -of `NATIVE_LANGUAGE`, which controls semantic-contract interpretation. +## Regeneration and rebuilds + +Configuration never inspects sources, so the generated file list cannot depend +on what analysis finds. PRIK names the optional compilation units up front and +always writes them: a module that needs no collision-adapter unit still gets +`_adapters.c`, and a Fortran module that needs no bridge still gets +`bind_c__wrapper.f90`. An unused unit holds a small placeholder that +defines no symbol and compiles without warnings. A semantic edit then changes a +file's *contents* rather than the set of files, so editing a source never +requires re-running `cmake` to configure. + +The generated wrapper sources are custom-command outputs. Changing a semantic +source, contract, included C header, or Fortran `INCLUDE` file regenerates them +before CMake compiles the target, without a configure step. CMake recompiles +contract-first native implementations independently. A build with no changes +reruns neither generation nor compilation. + +Transitive semantic inputs -- a nested C header, a Fortran `INCLUDE` file, an +imported contract -- reach CMake through the dependency file PRIK writes during +generation, so changing one reruns generation on the next build. This uses +`add_custom_command(DEPFILE)`, which is why the helper requires CMake 3.21 or +newer. ## Standalone generated project @@ -295,8 +306,12 @@ python3 -m prik generate --cmake contracts/solver.pyi \ The generated `CMakeLists.txt` loads `UsePRIK.cmake` and calls `prik_add_module()`. `UsePRIK.cmake` integrates PRIK into an existing CMake project; `prik generate --cmake` creates a standalone CMake project that uses -that same helper. `--native-linker-language fortran` emits the explicit raw -library annotation when standalone input requires the Fortran linker. +that same helper. + +### How CLI options map + +`--native-linker-language fortran` emits the explicit raw library annotation +when standalone input requires the Fortran linker. Native link inputs keep the meaning they have on the command line. `--native-objects` and `--native-link-item object:`, `archive:`, and From 27f82398ff05d9bff1f05cf63c1c4d7b075ba366 Mon Sep 17 00:00:00 2001 From: said Date: Sun, 13 Sep 2026 11:37:03 +0100 Subject: [PATCH 15/15] codex: correct which PRIK each discovery route resolves "Every route above answers for whichever prik the shell resolves" was wrong for scikit-build-core: that route never consults the shell. The backend reads the cmake.module entry point from the PRIK installed in its own build environment, which is the distribution named in build-system.requires and need not be the one on PATH. The sentence now scopes the claim to the three command-line routes and states what scikit-build-core uses instead. The discovery table also gains the distinction a large project needs: PRIK_DIR is package-specific, while CMAKE_PREFIX_PATH is the search path every find_package() call shares. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KMz3kPhNruZUmNkDAMT9KJ --- docs/user/guide/cmake.md | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/docs/user/guide/cmake.md b/docs/user/guide/cmake.md index d27ee7b15..f36cc78e7 100644 --- a/docs/user/guide/cmake.md +++ b/docs/user/guide/cmake.md @@ -72,6 +72,10 @@ helper, and these routes differ only in how CMake reaches it: | Module path | `include(UsePRIK)` | `-DCMAKE_MODULE_PATH="$(prik cmake-dir)"` | | scikit-build-core | `include(UsePRIK)` | nothing; the backend reads PRIK's `cmake.module` entry point | +`PRIK_DIR` is package-specific, so setting it does not affect how other CMake +packages are found; `CMAKE_PREFIX_PATH` is the broader search path every +`find_package()` call shares. + `prik install-dir` prints the prefix this PRIK's own installation wrote its data files under, which carries the same modules in `share/prik/cmake` and also resolves everything else installed there. The prefix comes from that @@ -97,10 +101,12 @@ the wheel is one command: python3 -m pip wheel . --no-deps --wheel-dir dist ``` -Every route above answers for whichever `prik` the shell resolves. When the -build must instead match the interpreter CMake itself selected -- several -environments on one machine, or a `Python_EXECUTABLE` the project pins -- ask -that interpreter, which also needs no `-D` argument: +The three command-line routes above use whichever `prik` the shell resolves. +scikit-build-core instead uses the PRIK installed in its build environment, +which it finds through the `cmake.module` entry point. When the build must +match the interpreter CMake itself selected -- several environments on one +machine, or a `Python_EXECUTABLE` the project pins -- ask that interpreter, +which also needs no `-D` argument: ```cmake execute_process(