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 7fe984e11..dcbf49a6b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,44 @@ 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 while preserving per-source flags, + 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. + +- CMake dependency targets now propagate their native compile usage + 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. + +- 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 + 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. + - 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..56620f192 --- /dev/null +++ b/cmake/UsePRIK.cmake @@ -0,0 +1,578 @@ +#[=======================================================================[.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}" _lower_suffix) + if(language STREQUAL "fortran") + 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 _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() + +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) + 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 ${_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}" ${_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}) + 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_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() + endif() +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)(=|$)") + 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 NO_STANDARD_LOGICALS) + set(_one_value_arguments CONTRACT NATIVE_LANGUAGE LINKER_LANGUAGE) + set(_multi_value_arguments + SOURCES + FORTRAN_SOURCES + C_SOURCES + INCLUDE_DIRS + MODULE_DIRS + FORTRAN_FLAGS + C_FLAGS + WRAPPER_FORTRAN_FLAGS + WRAPPER_C_FLAGS + LINK_LIBRARIES + LIBRARY_DIRS + 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) + + 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. " + "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() + 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 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") + 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}) + _prik_make_absolute_paths(_prik_library_dirs ${PRIK_LIBRARY_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_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_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) + else() + 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) + 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) + + 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() + 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}") + + 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() + + 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() + 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}") + # 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() + 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}" 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 + 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() + + 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_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}" + ) + endif() + 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) + 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() + set_target_properties("${name}" PROPERTIES PREFIX "" OUTPUT_NAME "${name}") + 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() + + target_include_directories( + "${name}" + PRIVATE + "${_prik_output_dir}" + "${_prik_numpy_include}" + ${_prik_include_dirs} + ${_prik_module_dirs} + ) + 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() +endfunction() diff --git a/docs/developer/packages/pipeline.md b/docs/developer/packages/pipeline.md index 476269185..7dd813ef3 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,18 @@ 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 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. 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, 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 new file mode 100644 index 000000000..b77256596 --- /dev/null +++ b/docs/user/guide/cmake.md @@ -0,0 +1,225 @@ +--- +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 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 + +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`, native and generated-source compile flag groups, +`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: + +```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, 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: + +- `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. +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; 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 +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: + +```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. + +```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 +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 +plan and the extension uses CMake's Fortran linker driver. This is independent +of `NATIVE_LANGUAGE`, which controls semantic-contract interpretation. + +## 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. `--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/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..4660aa1a4 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 @@ -98,8 +98,9 @@ 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-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. | +| `--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. 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. | | `--positional-only` | For Fortran and C, exposes every wrapper whose arguments are all required as positional-only, renaming them `arg0`..`argN`. | @@ -197,7 +198,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 +207,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,14 +223,28 @@ 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. +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` initializes CMake interprocedural optimization before PRIK creates its +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` 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/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/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/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..a9ca96ef2 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 = ( @@ -933,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) ) @@ -954,7 +958,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: @@ -980,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) @@ -1043,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) @@ -1070,11 +1076,22 @@ 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, "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": _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 +1170,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 +1338,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 +1432,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, @@ -1443,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), @@ -1459,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), @@ -1467,6 +1504,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), + _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, @@ -1489,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 @@ -1503,12 +1543,15 @@ 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), 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), + _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), @@ -1548,8 +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), @@ -1897,6 +1943,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"): @@ -2273,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", @@ -2308,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", @@ -2369,6 +2424,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, @@ -2383,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, @@ -2393,6 +2453,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 +2796,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,7 +2837,24 @@ 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) + 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) diff --git a/prik/cmake.py b/prik/cmake.py new file mode 100644 index 000000000..7ac3067fc --- /dev/null +++ b/prik/cmake.py @@ -0,0 +1,347 @@ +"""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}") + + 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 (("--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), + ("--collision-adapter-all", args.collision_adapter_all), + ("--positional-only", args.positional_only), + ): + if enabled: + add_option(option) + 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[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(_link_item_path(Path(str(item["path"])).resolve(), base)) + 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)) + 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: + 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 ()) + 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, + contract=contract, + native_fortran=native_fortran, + native_c=native_c, + ) + + +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})", + "", + "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)", + "", + ] + if lto: + lines.extend(("set(CMAKE_INTERPROCEDURAL_OPTIMIZATION TRUE)", "")) + lines.extend( + [ + "prik_add_module(", + f" {module_name}", + ] + ) + return lines + + +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))}") + 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) + 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) + 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_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) + # 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: + lines.append(" NO_STANDARD_LOGICALS") + _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) + 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" + 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, lto=args.lto) + _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/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/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..515bda8e3 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 @@ -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]] = (), @@ -1093,19 +1198,22 @@ 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 - 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() + native_dependencies = tuple(native_dependencies) + compiler = compiler or _new_compiler() bridge_objects, binding_objects = _generated_wrapper_object_stages( rendered, output_path, @@ -1118,6 +1226,51 @@ def _build_generated_wrapper_extension( ) ), ) + 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) + 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) + + # Prepare generated-object inputs and their native support files. native_support_imports = _generated_wrapper_native_support_imports(rendered.native_support_keys) install_native_support( native_support_imports, @@ -1140,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) @@ -1169,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, ) @@ -1618,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 @@ -2076,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. @@ -2111,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) ), @@ -2120,6 +2273,7 @@ def _native_build_plan( include_dirs=include_dirs, library_dirs=library_dirs, link_items=link_items, + linker_language=linker_language, ) @@ -2226,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. @@ -2259,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 @@ -2284,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, ) @@ -2307,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 ())) + ) ) @@ -2379,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) @@ -2398,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 @@ -2470,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 ], @@ -2488,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, } @@ -2514,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, @@ -3140,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. @@ -3176,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( @@ -3285,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, @@ -3294,6 +3480,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, + _external_native_implementation: bool = False, ) -> WrapperBuildResult: """Build a Python extension from one or more Fortran source files. @@ -3346,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 @@ -3389,13 +3578,16 @@ 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() # 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 () @@ -3411,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, @@ -3437,7 +3631,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) @@ -3448,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. @@ -3457,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, @@ -3466,6 +3665,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, @@ -3494,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, @@ -3504,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, @@ -3517,6 +3719,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, + _external_native_implementation: bool = False, ) -> WrapperBuildResult: """Build a direct-only C extension from explicit C implementation sources. @@ -3530,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 @@ -3549,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) @@ -3557,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, @@ -3565,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( @@ -3577,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. @@ -3619,13 +3829,19 @@ 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, + compiler=compiler, ) - 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) result = _build_generated_wrapper_extension( @@ -3633,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), @@ -3642,6 +3859,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, @@ -3675,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, @@ -3691,6 +3910,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, + _external_native_implementation: bool = False, ) -> WrapperBuildResult: """Build a Python extension from an editable semantic ``.pyi`` contract. @@ -3723,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. @@ -3767,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() @@ -3788,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) @@ -3823,10 +4049,10 @@ 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) + # 3. Select the compiler profile and complete the native compilation plan. compiler = _new_compiler( execute_commands=not generation_only, debug=wrapper_compiler_debug, @@ -3835,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) @@ -3844,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), @@ -3853,6 +4085,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, @@ -3971,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) @@ -4013,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/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/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 new file mode 100644 index 000000000..2ac8cca55 --- /dev/null +++ b/tests/fortran/infrastructure/building/end_to_end/test_cmake_builds.py @@ -0,0 +1,1644 @@ +"""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 venv + +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, + 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" + 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, + 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"): + 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')}") + 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"], environment=environment) + + +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 _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( + 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_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_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, + 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, + 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'"${{CMAKE_CURRENT_LIST_DIR}}/{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_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" + 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" + 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 + 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 + + +@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, + 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("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, + 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("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): + 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, + 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, 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_C_COMPILER={shutil.which('gcc')}", + 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, + 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_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 + 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, + 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) + + +@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, + ) + 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, + ) + 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 numpy, installed_square; " + "assert installed_square.installed_square(numpy.float64(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 ff7a91d81..405ebc92b 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"), ( @@ -407,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}, } @@ -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)