From 038fbcd2129ff93b1712cfcee75f3951b0121a73 Mon Sep 17 00:00:00 2001 From: said Date: Thu, 10 Sep 2026 07:17:16 +0100 Subject: [PATCH 1/5] codex: add CMake integration --- CHANGELOG.md | 5 + MANIFEST.in | 1 + cmake/UsePRIK.cmake | 345 ++++++++++ docs/user/guide/cmake.md | 110 +++ docs/user/guide/index.md | 4 +- docs/user/reference/cli-commands.md | 15 +- docs/user/reference/index.md | 2 + mkdocs.yml | 1 + prik/cli.py | 59 +- prik/cmake.py | 305 +++++++++ prik/compiler/native_support.py | 13 +- prik/pipeline/build.py | 67 +- pyproject.toml | 3 + .../building/end_to_end/test_cmake_builds.py | 647 ++++++++++++++++++ .../cli/pipeline/test_argument_contract.py | 14 +- 15 files changed, 1559 insertions(+), 32 deletions(-) create mode 100644 cmake/UsePRIK.cmake create mode 100644 docs/user/guide/cmake.md create mode 100644 prik/cmake.py create mode 100644 tests/fortran/infrastructure/building/end_to_end/test_cmake_builds.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 7fe984e11..9606ff6e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ release tags add a leading `v` to the package version. ## Unreleased +- Added CMake integration through the packaged `UsePRIK.cmake` helper and a + `prik generate --cmake` standalone-project mode. CMake generates PRIK wrapper + sources as build outputs and owns native compilation, linking, external + targets, and incremental rebuilds. + - Array handles support allocatable and pointer arguments, results, module variables, derived fields, optional arguments, and matching ordinary-array parameters. Numeric and character arrays accept supported forward and diff --git a/MANIFEST.in b/MANIFEST.in index f96ea7399..6792ee98f 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,3 +1,4 @@ include CHANGELOG.md include CITATION.cff include .artifacts/.gitignore +recursive-include cmake *.cmake diff --git a/cmake/UsePRIK.cmake b/cmake/UsePRIK.cmake new file mode 100644 index 000000000..685b54cf3 --- /dev/null +++ b/cmake/UsePRIK.cmake @@ -0,0 +1,345 @@ +#[=======================================================================[.rst: + +UsePRIK +------- + +Create a Python extension whose wrapper sources are generated by PRIK and +whose native compilation and linking are owned by CMake. + +]=======================================================================] + +include_guard(GLOBAL) +include(CMakeParseArguments) + +if(NOT Python_EXECUTABLE OR NOT COMMAND Python_add_library) + find_package(Python COMPONENTS Interpreter Development.Module REQUIRED) +endif() + +function(_prik_make_absolute_paths output_variable) + set(_absolute_paths) + foreach(_path IN LISTS ARGN) + if(IS_ABSOLUTE "${_path}") + list(APPEND _absolute_paths "${_path}") + else() + get_filename_component(_absolute_path "${_path}" ABSOLUTE BASE_DIR "${CMAKE_CURRENT_SOURCE_DIR}") + list(APPEND _absolute_paths "${_absolute_path}") + endif() + endforeach() + set(${output_variable} "${_absolute_paths}" PARENT_SCOPE) +endfunction() + +function(_prik_validate_source_suffixes language sources) + foreach(_source IN LISTS ${sources}) + get_filename_component(_suffix "${_source}" LAST_EXT) + string(TOLOWER "${_suffix}" _suffix) + if(language STREQUAL "fortran") + if(NOT _suffix MATCHES "^\\.(f|f03|f08|f77|f90|f95|for|ftn)$") + message(FATAL_ERROR "PRIK Fortran input is not a supported source: ${_source}") + endif() + elseif(NOT _suffix STREQUAL ".c") + message(FATAL_ERROR "PRIK C input is not a supported source: ${_source}") + endif() + endforeach() +endfunction() + +function(_prik_append_cli_flags command option flags) + set(_command ${${command}}) + foreach(_flag IN LISTS ${flags}) + list(APPEND _command "${option}=${_flag}") + endforeach() + set(${command} "${_command}" PARENT_SCOPE) +endfunction() + +function(_prik_json_string_list output_variable json key) + string(JSON _prik_item_count ERROR_VARIABLE _prik_json_error LENGTH "${json}" "${key}") + if(_prik_json_error) + message(FATAL_ERROR "PRIK did not return ${key}: ${_prik_json_error}") + endif() + + set(_prik_items) + if(_prik_item_count GREATER 0) + math(EXPR _prik_last_item_index "${_prik_item_count} - 1") + foreach(_prik_item_index RANGE 0 ${_prik_last_item_index}) + string(JSON _prik_item GET "${json}" "${key}" ${_prik_item_index}) + list(APPEND _prik_items "${_prik_item}") + endforeach() + endif() + set(${output_variable} "${_prik_items}" PARENT_SCOPE) +endfunction() + +function(_prik_rebase_generated_paths output_variable paths source_root output_root) + set(_prik_rebased_paths) + foreach(_prik_path IN LISTS ${paths}) + file(RELATIVE_PATH _prik_relative_path "${source_root}" "${_prik_path}") + if(_prik_relative_path MATCHES "^\\.\\.") + message(FATAL_ERROR "PRIK generated output is outside its output directory: ${_prik_path}") + endif() + list(APPEND _prik_rebased_paths "${output_root}/${_prik_relative_path}") + endforeach() + set(${output_variable} "${_prik_rebased_paths}" PARENT_SCOPE) +endfunction() + +function(_prik_validate_args args) + foreach(_prik_arg IN LISTS ${args}) + if(_prik_arg MATCHES "^--(build-manifest|cmake|json|language|makefile|module-name|native-c-sources|native-fortran-sources|no-compile-input-sources|out|out-dir|plan|pyi|sources)(=|$)") + message(FATAL_ERROR "PRIK_ARGS cannot override prik_add_module build ownership: ${_prik_arg}") + endif() + endforeach() +endfunction() + +function(prik_add_module name) + if(NOT name MATCHES "^[A-Za-z_][A-Za-z0-9_]*$") + message(FATAL_ERROR "PRIK module name must be a Python/CMake identifier: ${name}") + endif() + if(TARGET "${name}") + message(FATAL_ERROR "PRIK module target already exists: ${name}") + endif() + + set(_options NO_COMPILE_INPUT_SOURCES) + set(_one_value_arguments CONTRACT) + set(_multi_value_arguments + SOURCES + FORTRAN_SOURCES + C_SOURCES + INCLUDE_DIRS + MODULE_DIRS + FORTRAN_FLAGS + C_FLAGS + LINK_LIBRARIES + LINK_OPTIONS + PRIK_ARGS + ) + cmake_parse_arguments(PRIK "${_options}" "${_one_value_arguments}" "${_multi_value_arguments}" ${ARGN}) + if(PRIK_UNPARSED_ARGUMENTS) + message(FATAL_ERROR "Unknown prik_add_module arguments: ${PRIK_UNPARSED_ARGUMENTS}") + endif() + _prik_validate_args(PRIK_PRIK_ARGS) + + if(PRIK_CONTRACT AND PRIK_SOURCES) + message(FATAL_ERROR "prik_add_module(${name}) cannot combine CONTRACT and SOURCES") + endif() + if(PRIK_NO_COMPILE_INPUT_SOURCES AND NOT PRIK_SOURCES) + message(FATAL_ERROR "prik_add_module(${name}) uses NO_COMPILE_INPUT_SOURCES only with SOURCES") + endif() + if(PRIK_NO_COMPILE_INPUT_SOURCES AND NOT PRIK_FORTRAN_SOURCES AND NOT PRIK_C_SOURCES) + message(FATAL_ERROR "prik_add_module(${name}) requires native implementation sources") + endif() + if(NOT PRIK_CONTRACT AND NOT PRIK_SOURCES AND NOT PRIK_FORTRAN_SOURCES AND NOT PRIK_C_SOURCES) + message(FATAL_ERROR "prik_add_module(${name}) requires SOURCES, FORTRAN_SOURCES, C_SOURCES, or CONTRACT") + endif() + + _prik_make_absolute_paths(_prik_contract "${PRIK_CONTRACT}") + _prik_make_absolute_paths(_prik_sources ${PRIK_SOURCES}) + _prik_make_absolute_paths(_prik_fortran_sources ${PRIK_FORTRAN_SOURCES}) + _prik_make_absolute_paths(_prik_c_sources ${PRIK_C_SOURCES}) + _prik_make_absolute_paths(_prik_include_dirs ${PRIK_INCLUDE_DIRS}) + _prik_make_absolute_paths(_prik_module_dirs ${PRIK_MODULE_DIRS}) + + if(_prik_contract) + set(_prik_wrapper_sources) + set(_prik_native_fortran_sources ${_prik_fortran_sources}) + set(_prik_native_c_sources ${_prik_c_sources}) + if(_prik_native_fortran_sources) + set(_prik_language fortran) + elseif(_prik_native_c_sources) + set(_prik_language c) + else() + message(FATAL_ERROR "PRIK contract module ${name} requires native FORTRAN_SOURCES or C_SOURCES") + endif() + else() + if(_prik_sources) + set(_prik_wrapper_sources ${_prik_sources}) + set(_prik_native_fortran_sources ${_prik_fortran_sources}) + set(_prik_native_c_sources ${_prik_c_sources}) + elseif(_prik_fortran_sources AND NOT _prik_c_sources) + set(_prik_wrapper_sources ${_prik_fortran_sources}) + set(_prik_native_fortran_sources) + set(_prik_native_c_sources) + elseif(_prik_c_sources AND NOT _prik_fortran_sources) + set(_prik_wrapper_sources ${_prik_c_sources}) + set(_prik_native_fortran_sources) + set(_prik_native_c_sources) + else() + message(FATAL_ERROR "PRIK module ${name} cannot mix Fortran and C wrapper sources") + endif() + + list(GET _prik_wrapper_sources 0 _prik_first_source) + get_filename_component(_prik_first_suffix "${_prik_first_source}" LAST_EXT) + string(TOLOWER "${_prik_first_suffix}" _prik_first_suffix) + if(_prik_first_suffix STREQUAL ".c") + set(_prik_language c) + else() + set(_prik_language fortran) + endif() + endif() + + _prik_validate_source_suffixes("${_prik_language}" _prik_wrapper_sources) + _prik_validate_source_suffixes(fortran _prik_native_fortran_sources) + _prik_validate_source_suffixes(c _prik_native_c_sources) + + set(_prik_output_dir "${CMAKE_CURRENT_BINARY_DIR}/prik/${name}") + file(MAKE_DIRECTORY "${_prik_output_dir}") + + if(_prik_language STREQUAL "fortran") + set(_prik_analysis_compiler "${CMAKE_Fortran_COMPILER}") + else() + set(_prik_analysis_compiler "${CMAKE_C_COMPILER}") + endif() + if(NOT _prik_analysis_compiler) + message(FATAL_ERROR "PRIK module ${name} requires CMake to enable ${_prik_language}") + endif() + + set(_prik_generate_command "${Python_EXECUTABLE}" -m prik generate --sources) + if(_prik_contract) + list(APPEND _prik_generate_command "${_prik_contract}") + else() + list(APPEND _prik_generate_command ${_prik_wrapper_sources}) + endif() + if(PRIK_NO_COMPILE_INPUT_SOURCES) + list(APPEND _prik_generate_command --no-compile-input-sources) + endif() + list(APPEND _prik_generate_command --module-name "${name}") + list(APPEND _prik_generate_command --language "${_prik_language}") + # Later PRIK_ARGS may override this when source analysis requires a + # distinct compiler, but CMake's selected toolchain is the default. + list(APPEND _prik_generate_command --compiler "${_prik_analysis_compiler}") + _prik_append_cli_flags(_prik_generate_command --native-compile-flags PRIK_FORTRAN_FLAGS) + _prik_append_cli_flags(_prik_generate_command --native-c-compile-flags PRIK_C_FLAGS) + foreach(_include_dir IN LISTS _prik_include_dirs _prik_module_dirs) + list(APPEND _prik_generate_command -I "${_include_dir}") + endforeach() + if(_prik_native_fortran_sources) + list(APPEND _prik_generate_command --native-fortran-sources ${_prik_native_fortran_sources}) + endif() + if(_prik_native_c_sources) + list(APPEND _prik_generate_command --native-c-sources ${_prik_native_c_sources}) + endif() + list(APPEND _prik_generate_command ${PRIK_PRIK_ARGS}) + list(APPEND _prik_generate_command --json) + + # Ask PRIK's completed plan for canonical output names without materializing + # sources. The actual source generation remains the custom command below. + set(_prik_plan_dir "${CMAKE_CURRENT_BINARY_DIR}/CMakeFiles/prik-plan/${name}") + file(REMOVE_RECURSE "${_prik_plan_dir}") + set(_prik_plan_command ${_prik_generate_command} --plan --out-dir "${_prik_plan_dir}") + + execute_process( + COMMAND ${_prik_plan_command} + WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}" + RESULT_VARIABLE _prik_configure_result + OUTPUT_VARIABLE _prik_plan_json + ERROR_VARIABLE _prik_configure_error + ) + if(NOT _prik_configure_result EQUAL 0) + message(FATAL_ERROR "PRIK wrapper generation failed for ${name}:\n${_prik_configure_error}") + endif() + + _prik_json_string_list(_prik_planned_sources "${_prik_plan_json}" generated_sources) + if(NOT _prik_planned_sources) + message(FATAL_ERROR "PRIK did not return generated wrapper sources for ${name}") + endif() + _prik_json_string_list(_prik_planned_outputs "${_prik_plan_json}" generated_files) + if(NOT _prik_planned_outputs) + message(FATAL_ERROR "PRIK did not return generated outputs for ${name}") + endif() + _prik_json_string_list(_prik_semantic_dependencies "${_prik_plan_json}" sources) + + string( + JSON _prik_native_unit_count + ERROR_VARIABLE _prik_native_plan_error + LENGTH "${_prik_plan_json}" native_build_plan compilation_units + ) + if(_prik_native_plan_error) + message(FATAL_ERROR "PRIK did not return native compilation units: ${_prik_native_plan_error}") + endif() + set(_prik_native_target_sources) + if(_prik_native_unit_count GREATER 0) + math(EXPR _prik_last_native_unit_index "${_prik_native_unit_count} - 1") + foreach(_prik_native_unit_index RANGE 0 ${_prik_last_native_unit_index}) + string( + JSON _prik_native_target_source + GET "${_prik_plan_json}" native_build_plan compilation_units ${_prik_native_unit_index} source + ) + list(APPEND _prik_native_target_sources "${_prik_native_target_source}") + endforeach() + endif() + + execute_process( + COMMAND "${Python_EXECUTABLE}" -c "import numpy; print(numpy.get_include())" + RESULT_VARIABLE _prik_numpy_result + OUTPUT_VARIABLE _prik_numpy_include + ERROR_VARIABLE _prik_numpy_error + OUTPUT_STRIP_TRAILING_WHITESPACE + ) + if(NOT _prik_numpy_result EQUAL 0) + message(FATAL_ERROR "PRIK requires NumPy headers for ${name}:\n${_prik_numpy_error}") + endif() + _prik_rebase_generated_paths( + _prik_generated_sources _prik_planned_sources "${_prik_plan_dir}" "${_prik_output_dir}" + ) + _prik_rebase_generated_paths( + _prik_generation_outputs _prik_planned_outputs "${_prik_plan_dir}" "${_prik_output_dir}" + ) + + # Semantic inputs can add or remove a bridge source, so make CMake + # reconfigure before it evaluates the target source list again. + set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS ${_prik_semantic_dependencies}) + + set(_prik_command_signature_dir "${CMAKE_CURRENT_BINARY_DIR}/CMakeFiles/prik") + file(MAKE_DIRECTORY "${_prik_command_signature_dir}") + set(_prik_command_signature "${_prik_command_signature_dir}/${name}-generation-command.txt") + string(JOIN "\n" _prik_command_signature_text ${_prik_generate_command} --out-dir "${_prik_output_dir}") + file(CONFIGURE OUTPUT "${_prik_command_signature}" CONTENT "${_prik_command_signature_text}\n" @ONLY) + + set(_prik_dependencies + ${_prik_semantic_dependencies} + "${_prik_command_signature}" + "${CMAKE_CURRENT_FUNCTION_LIST_FILE}" + ) + list(FILTER _prik_dependencies EXCLUDE REGEX "^$") + set(_prik_actual_generate_command ${_prik_generate_command} --out-dir "${_prik_output_dir}") + file(REMOVE_RECURSE "${_prik_plan_dir}") + add_custom_command( + OUTPUT ${_prik_generation_outputs} + COMMAND ${_prik_actual_generate_command} + DEPENDS ${_prik_dependencies} + WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}" + VERBATIM + COMMENT "Generate PRIK wrapper sources for ${name}" + ) + + Python_add_library("${name}" MODULE WITH_SOABI ${_prik_generated_sources}) + set(_prik_additional_outputs ${_prik_generation_outputs}) + list(REMOVE_ITEM _prik_additional_outputs ${_prik_generated_sources}) + if(_prik_additional_outputs) + target_sources("${name}" PRIVATE ${_prik_additional_outputs}) + endif() + if(_prik_native_target_sources) + target_sources("${name}" PRIVATE ${_prik_native_target_sources}) + endif() + set_target_properties("${name}" PROPERTIES PREFIX "" OUTPUT_NAME "${name}") + if(_prik_language STREQUAL "fortran" OR _prik_native_fortran_sources) + set_target_properties("${name}" PROPERTIES Fortran_MODULE_DIRECTORY "${_prik_output_dir}") + endif() + + target_include_directories( + "${name}" + PRIVATE + "${_prik_output_dir}" + "${_prik_numpy_include}" + ${_prik_include_dirs} + ${_prik_module_dirs} + ) + foreach(_flag IN LISTS PRIK_FORTRAN_FLAGS) + target_compile_options("${name}" PRIVATE "$<$:${_flag}>") + endforeach() + foreach(_flag IN LISTS PRIK_C_FLAGS) + target_compile_options("${name}" PRIVATE "$<$:${_flag}>") + endforeach() + if(PRIK_LINK_LIBRARIES) + target_link_libraries("${name}" PRIVATE ${PRIK_LINK_LIBRARIES}) + endif() + if(PRIK_LINK_OPTIONS) + target_link_options("${name}" PRIVATE ${PRIK_LINK_OPTIONS}) + endif() +endfunction() diff --git a/docs/user/guide/cmake.md b/docs/user/guide/cmake.md new file mode 100644 index 000000000..7e5cf31fc --- /dev/null +++ b/docs/user/guide/cmake.md @@ -0,0 +1,110 @@ +--- +title: CMake Builds +description: Build PRIK Python extensions from an existing or generated CMake project +audience: users +prerequisites: building the shared library, CMake, Python development files +related: building-shared-library.md, ../reference/cli-commands.md +status: maintained +publication: reviewed +--- + +# CMake Builds + +Use CMake when its toolchain, dependency targets, and build scheduling should +own compilation and linking. PRIK still parses the native inputs, completes +wrapper policy, and generates the wrapper and any Fortran bridge sources. By +default, PRIK uses CMake's selected C or Fortran compiler for source analysis. + +## Existing CMake project + +Install PRIK, make its `cmake` directory available through +`CMAKE_MODULE_PATH`, and include the packaged helper: + +```cmake +cmake_minimum_required(VERSION 3.20) + +project(MyPhysics LANGUAGES C Fortran) + +find_package( + Python + COMPONENTS Interpreter Development.Module + REQUIRED +) + +# Ask PRIK's Python environment for its packaged CMake helper. +execute_process( + COMMAND "${Python_EXECUTABLE}" -c "from prik.cmake import cmake_module_dir; print(cmake_module_dir().as_posix())" + RESULT_VARIABLE PRIK_CMAKE_MODULE_RESULT + OUTPUT_VARIABLE PRIK_CMAKE_MODULE_DIR + ERROR_VARIABLE PRIK_CMAKE_MODULE_ERROR + OUTPUT_STRIP_TRAILING_WHITESPACE +) +if(NOT PRIK_CMAKE_MODULE_RESULT EQUAL 0) + message(FATAL_ERROR "Cannot locate UsePRIK.cmake: ${PRIK_CMAKE_MODULE_ERROR}") +endif() +list(APPEND CMAKE_MODULE_PATH "${PRIK_CMAKE_MODULE_DIR}") +include(UsePRIK) + +prik_add_module( + physics + FORTRAN_SOURCES + solver.f90 + matrix.f90 +) +``` + +Then configure and build the extension: + +```bash +cmake -S . -B build +cmake --build build +``` + +`prik_add_module()` also accepts `SOURCES` for source-first input, `CONTRACT` +with `FORTRAN_SOURCES` or `C_SOURCES` for an authored semantic `.pyi`, +`INCLUDE_DIRS`, `MODULE_DIRS`, language-specific compile flags, +`LINK_LIBRARIES`, `LINK_OPTIONS`, and additional `PRIK_ARGS`. Use +`NO_COMPILE_INPUT_SOURCES` when `SOURCES` supplies only the public interface +and `FORTRAN_SOURCES` or `C_SOURCES` supplies its implementation. The +generated wrapper sources are custom-command outputs. Changing a semantic +source or contract regenerates them before CMake compiles the target; CMake +recompiles contract-first native implementations independently. + +External dependencies remain CMake dependencies. For example, CMake can find +BLAS and pass its target to the PRIK extension: + +```cmake +find_package(BLAS REQUIRED) + +prik_add_module( + blas_example + FORTRAN_SOURCES blas_example.f90 + LINK_LIBRARIES BLAS::BLAS +) +``` + +The same form accepts normal project targets such as `native_math` and +`OpenMP::OpenMP_Fortran`; they remain target-oriented CMake link inputs. + +## Standalone generated project + +Generate a small CMake project from native sources: + +```bash +python3 -m prik generate --cmake solver.f90 --out-dir build/solver +cmake -S build/solver -B build/solver/cmake-build +cmake --build build/solver/cmake-build +``` + +For an authored contract, provide its implementation sources as usual: + +```bash +python3 -m prik generate --cmake contracts/solver.pyi \ + --native-fortran-sources solver.f90 \ + --out-dir build/solver +``` + +The generated `CMakeLists.txt` loads `UsePRIK.cmake` and calls +`prik_add_module()`. `UsePRIK.cmake` integrates PRIK into an existing CMake +project; `prik generate --cmake` creates a standalone CMake project that uses +that same helper. diff --git a/docs/user/guide/index.md b/docs/user/guide/index.md index 228a22a30..c8296a717 100644 --- a/docs/user/guide/index.md +++ b/docs/user/guide/index.md @@ -45,7 +45,9 @@ workflow that matches how you run PRIK. ## Build Workflows - [Building the Shared Library](building-shared-library.md) — compilers, - source sets, output placement, and Makefiles + source sets, output placement, Makefiles, and CMake +- [CMake Builds](cmake.md) — integrate PRIK into an existing CMake project or + generate a standalone one - [IPython and Jupyter Notebooks](notebooks.md) — compile Fortran and C cells and edit semantic contracts interactively diff --git a/docs/user/reference/cli-commands.md b/docs/user/reference/cli-commands.md index 25c26e41c..27705fb6e 100644 --- a/docs/user/reference/cli-commands.md +++ b/docs/user/reference/cli-commands.md @@ -22,7 +22,7 @@ python3 -m prik {parse,semantics,generate,probe} [OPTIONS] ... | no subcommand | Builds one importable extension from Fortran source, a supported C source, or a semantic `.pyi` contract. | | `parse` | Prints parser facts and diagnostics. | | `semantics` | Prints a human-readable semantic-IR report; `--json` selects the complete JSON record. | -| `generate` | Writes `.pyi` contracts, wrapper sources, or a Makefile without compiling. | +| `generate` | Writes `.pyi` contracts, wrapper sources, a Makefile, or a CMake project without compiling. | | `probe` | Prints compiler-target datatype and ABI facts. | ## Getting help @@ -197,7 +197,7 @@ Support](../language-support/c-support.md) before building a C API. `generate` requires exactly one output mode: ```bash -python3 -m prik generate (--pyi | --sources | --makefile) INPUT [INPUT ...] [OPTIONS] +python3 -m prik generate (--pyi | --sources | --makefile | --cmake) INPUT [INPUT ...] [OPTIONS] python3 -m prik generate (--sources | --makefile) --build-manifest PATH [OVERRIDES] ``` @@ -206,11 +206,14 @@ python3 -m prik generate (--sources | --makefile) --build-manifest PATH [OVERRID | `--pyi` | Writes the editable semantic `.pyi` contract. | | `--sources` | Writes wrapper sources without compiling. | | `--makefile` | Writes wrapper sources, the replay manifest when applicable, and `Makefile.prik`. | +| `--cmake` | Writes a standalone `CMakeLists.txt` that uses `UsePRIK.cmake`. | +| `--module-name NAME` | Sets the Python module name used by generated wrapper sources; `--cmake` requires an ASCII C target name. | ```bash python3 -m prik generate --pyi points.f90 --out contracts/points python3 -m prik generate --sources points.f90 --out-dir build python3 -m prik generate --makefile points.f90 --out-dir build +python3 -m prik generate --cmake points.f90 --out-dir build/points ``` For a C source contract, `--language c` is valid with `--pyi`: @@ -219,9 +222,11 @@ For a C source contract, `--language c` is valid with `--pyi`: python3 -m prik generate --pyi --language c path/to/api.c --out api.pyi ``` -`--sources` and `--makefile` still run preprocessing and semantic policy to -produce a valid wrapper plan; they skip object compilation and linking, and -use `--out-dir`. With no `--out`, `generate --pyi` prints every generated +`--sources` and `--makefile` run preprocessing and semantic policy to produce +a valid wrapper plan; they skip object compilation and linking, and use +`--out-dir`. `--cmake` writes the CMake project; its CMake configuration later +runs PRIK's wrapper-generation step, while CMake owns compilation and linking. +With no `--out`, `generate --pyi` prints every generated contract. For Fortran, `--out PATH` names a package directory containing `__init__.pyi` and any module leaves. For C, it names the single output `.pyi` file. Bare `--out` writes beside the inputs. The [source-to-contract diff --git a/docs/user/reference/index.md b/docs/user/reference/index.md index e6373325b..ecec46739 100644 --- a/docs/user/reference/index.md +++ b/docs/user/reference/index.md @@ -21,6 +21,8 @@ you have not. - [Python API](python-api.md) — the build entrypoints and advanced package imports. - [Build manifests and Makefiles](configuration-files.md) — how both files are generated, what they contain, and how to build or replay them. +- [CMake Builds](../guide/cmake.md) — the packaged CMake helper and standalone + CMake project generation. ## Contracts diff --git a/mkdocs.yml b/mkdocs.yml index 886c6d550..fd5caf6ec 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -71,6 +71,7 @@ nav: - Symbols, Headers, and Dependencies: user/guide/c/symbols-headers-and-dependencies.md - Build Workflows: - Building the Shared Library: user/guide/building-shared-library.md + - CMake Builds: user/guide/cmake.md - IPython and Jupyter Notebooks: user/guide/notebooks.md - Tutorials: - Run PRIK in a Notebook: user/tutorials/notebook-quickstart.md diff --git a/prik/cli.py b/prik/cli.py index b93d03b17..35cf88e20 100644 --- a/prik/cli.py +++ b/prik/cli.py @@ -62,7 +62,7 @@ _PARSE_USAGE = "%(prog)s INPUT [INPUT ...] [OPTIONS]" _SEMANTICS_USAGE = "%(prog)s INPUT [INPUT ...] [OPTIONS]" _GENERATE_USAGE = ( - "%(prog)s (--pyi | --sources | --makefile)\n" + "%(prog)s (--pyi | --sources | --makefile | --cmake)\n" " INPUT [INPUT ...] [OPTIONS]\n" " %(prog)s (--sources | --makefile)\n" " --build-manifest PATH [OVERRIDES]" @@ -161,7 +161,10 @@ " python3 -m prik generate --sources points.f90 --out-dir build\n" "\n" " Reproducible Makefile build:\n" - " python3 -m prik generate --makefile points.f90 --out-dir build\n\n" + " python3 -m prik generate --makefile points.f90 --out-dir build\n" + "\n" + " Standalone CMake project:\n" + " python3 -m prik generate --cmake points.f90 --out-dir build/points\n\n" f"{_POINTS_EXAMPLE_HELP}" ) _PROBE_HELP_EPILOG = ( @@ -954,7 +957,9 @@ def _wrapper_compile_options_used(args: argparse.Namespace) -> bool: def _is_wrapper_build(args: argparse.Namespace) -> bool: """Return whether the command projects and renders a wrapper plan.""" - return args.command == "build" or (args.command == "generate" and (args.generate_sources or args.makefile)) + return args.command == "build" or ( + args.command == "generate" and (args.generate_sources or args.makefile or getattr(args, "cmake", False)) + ) def _has_semantic_stage(args: argparse.Namespace) -> bool: @@ -1070,11 +1075,17 @@ def _validate_wrapper_build_options(args: argparse.Namespace, parser: argparse.A if not _is_wrapper_build(args): return if args.command == "generate" and args.out is not None: - parser.error("generate --sources/--makefile uses --out-dir, not --out") + parser.error("generate --sources/--makefile/--cmake uses --out-dir, not --out") + if args.command == "generate" and getattr(args, "module_name", None) is not None: + _validate_wrapper_out(argparse.Namespace(out=args.module_name), parser) + if getattr(args, "plan_only", False) and not (args.command == "generate" and args.generate_sources): + parser.error("--plan requires generate --sources") if args.command == "build": _validate_wrapper_out(args, parser) if _wrapper_build_uses_manifest(args): + if getattr(args, "cmake", False) or getattr(args, "plan_only", False): + parser.error("generate --cmake/--plan requires source or contract inputs, not --build-manifest") _validate_manifest_wrapper_options(args, parser) return @@ -1153,6 +1164,8 @@ def _validate_pyi_generation_options(args: argparse.Namespace, parser: argparse. invalid.append("--out-dir") if args.build_manifest is not None: invalid.append("--build-manifest") + if getattr(args, "module_name", None) is not None: + invalid.append("--module-name") if _native_link_options_used(args): invalid.append("native link options") if _wrapper_compile_options_used(args) or args.strict_wrapper_names: @@ -1319,6 +1332,8 @@ def _wrapper_shared_library_alias_path(result, raw_out: str | None) -> Path: def _wrapper_output_name(args: argparse.Namespace) -> str | None: + if getattr(args, "module_name", None) is not None: + return args.module_name if getattr(args, "out", None) is None: return None return Path(args.out).stem @@ -1411,6 +1426,17 @@ def _run_stage_reports_with_diagnostics(args: argparse.Namespace, preprocessing: def _run_wrap_build(args: argparse.Namespace, preprocessing: PreprocessingConfig): + if getattr(args, "cmake", False): + from prik.cmake import write_cmake_project + + return write_cmake_project( + paths=args.paths, + output_dir=getattr(args, "out_dir", None) or "__prik__", + language=args.language, + args=args, + native_link_items=_cli_native_link_items(getattr(args, "native_link_items", None)), + ) + from prik.pipeline.build import ( _build_manifest_native_language, build_c_extension, @@ -1467,6 +1493,7 @@ def record_total_build_time(elapsed: float) -> None: positional_only=getattr(args, "positional_only", False), makefile=getattr(args, "makefile", False), generate_sources=getattr(args, "generate_sources", False), + _plan_only=getattr(args, "plan_only", False), jobs=getattr(args, "jobs", None), standard_logicals=getattr(args, "standard_logicals", True), verbose=1 if getattr(args, "verbose", False) else 0, @@ -1509,6 +1536,7 @@ def record_total_build_time(elapsed: float) -> None: positional_only=getattr(args, "positional_only", False), makefile=getattr(args, "makefile", False), generate_sources=getattr(args, "generate_sources", False), + _plan_only=getattr(args, "plan_only", False), jobs=getattr(args, "jobs", None), verbose=1 if getattr(args, "verbose", False) else 0, wrapper_compiler_debug=getattr(args, "wrapper_compiler_debug", False), @@ -1550,6 +1578,7 @@ def record_total_build_time(elapsed: float) -> None: native_include_dirs=_cli_build_include_dirs(args), makefile=getattr(args, "makefile", False), generate_sources=getattr(args, "generate_sources", False), + _plan_only=getattr(args, "plan_only", False), jobs=getattr(args, "jobs", None), verbose=1 if getattr(args, "verbose", False) else 0, wrapper_compiler_debug=getattr(args, "wrapper_compiler_debug", False), @@ -1897,6 +1926,10 @@ def _print_wrap_build_output(args: argparse.Namespace, result) -> None: _print_verbose_total_build_time(args) return + if payload.get("cmake_project"): + print(f"Generated CMake project: {payload['cmake_project']}") + return + if payload.get("compiled", True): print(f"Built extension: {payload['shared_library']}") elif payload.get("build_makefile"): @@ -2369,6 +2402,8 @@ def _add_diagnostic_controls(group: argparse._ArgumentGroup, *, allow_verbose: b "pyi": False, "generate_sources": False, "makefile": False, + "cmake": False, + "plan_only": False, "show_vars": False, "print_limit": None, "vars_limit": None, @@ -2393,6 +2428,7 @@ def _add_diagnostic_controls(group: argparse._ArgumentGroup, *, allow_verbose: b "wrapper_fortran_flags": None, "wrapper_c_flags": None, "out": None, + "module_name": None, "out_dir": None, "verbose": False, "json": False, @@ -2735,11 +2771,16 @@ def _generate_parser(argv: list[str]) -> argparse.ArgumentParser: action="store_true", help="Generate wrapper sources and Makefile.prik without compiling", ) + modes.add_argument( + "--cmake", + action="store_true", + help="Generate a standalone CMake project that uses UsePRIK.cmake", + ) positional_group = parser.add_argument_group("positional arguments") _add_paths( positional_group, metavar="INPUT", - help_text="Source input(s), or one semantic .pyi contract for --sources/--makefile", + help_text="Source input(s), or one semantic .pyi contract for --sources/--makefile/--cmake", ) input_group = parser.add_argument_group("input options") _add_language_option( @@ -2771,8 +2812,14 @@ def _generate_parser(argv: list[str]) -> argparse.ArgumentParser: json_help="Print generated artifact metadata as JSON", out_help="Contract package directory for --pyi; bare --out writes beside inputs", out_metavar="PATH", - out_dir_help="Artifact directory for --sources/--makefile", + out_dir_help="Artifact directory for --sources/--makefile/--cmake", + ) + output_group.add_argument( + "--module-name", + metavar="NAME", + help="Python module name for generated wrapper sources", ) + output_group.add_argument("--plan", dest="plan_only", action="store_true", help=argparse.SUPPRESS) diagnostic_group = parser.add_argument_group("diagnostic options") _add_diagnostic_controls(diagnostic_group) return parser diff --git a/prik/cmake.py b/prik/cmake.py new file mode 100644 index 000000000..25bf74922 --- /dev/null +++ b/prik/cmake.py @@ -0,0 +1,305 @@ +"""Generate small CMake projects that use PRIK's packaged CMake helper.""" + +from __future__ import annotations + +from collections.abc import Iterable +from dataclasses import dataclass +import os +from pathlib import Path +import shlex +import sys +import sysconfig + + +@dataclass(frozen=True) +class CMakeProjectResult: + """Describe a generated standalone CMake project.""" + + module_name: str + output_dir: Path + cmake_lists: Path + + def to_dict(self) -> dict[str, object]: + """Return the generated project paths in the CLI result shape.""" + return { + "cmake_project": str(self.cmake_lists), + "module_name": self.module_name, + "output_dir": str(self.output_dir), + } + + +@dataclass(frozen=True) +class _CMakeModuleInputs: + """Normalized source declarations for one generated helper call.""" + + module_name: str + semantic_sources: tuple[Path, ...] + contract: Path | None + native_fortran: tuple[Path, ...] + native_c: tuple[Path, ...] + + +def _absolute_paths(paths: Iterable[str | Path]) -> tuple[Path, ...]: + return tuple(Path(path).resolve() for path in paths) + + +def _cmake_string(value: str | Path) -> str: + """Quote one literal for a generated CMake string.""" + return '"' + str(value).replace("\\", "/").replace('"', '\\"') + '"' + + +def _relative_path(path: Path, base: Path) -> str: + return Path(os.path.relpath(path, base)).as_posix() + + +def _helper_path() -> Path: + """Find the helper in a checkout or in the installed data directory.""" + candidates = [Path(__file__).resolve().parent.parent / "cmake" / "UsePRIK.cmake"] + data_root = sysconfig.get_path("data") + if data_root: + candidates.append(Path(data_root) / "share" / "prik" / "cmake" / "UsePRIK.cmake") + candidates.append(Path(sys.prefix) / "share" / "prik" / "cmake" / "UsePRIK.cmake") + for candidate in candidates: + if candidate.is_file(): + return candidate.resolve() + raise FileNotFoundError("Packaged PRIK CMake helper not found: cmake/UsePRIK.cmake") + + +def cmake_module_dir() -> Path: + """Return the directory containing PRIK's packaged CMake helper.""" + return _helper_path().parent + + +def _append_block(lines: list[str], keyword: str, values: Iterable[str], *, base: Path) -> None: + resolved = tuple(Path(value) for value in values) + if not resolved: + return + lines.append(f" {keyword}") + lines.extend(f" {_cmake_string(_relative_path(path, base))}" for path in resolved) + + +def _append_values(lines: list[str], keyword: str, values: Iterable[str]) -> None: + values = tuple(str(value) for value in values) + if not values: + return + lines.append(f" {keyword}") + lines.extend(f" {_cmake_string(value)}" for value in values) + + +def _flag_values(raw_flags: Iterable[str] | None) -> tuple[str, ...]: + values: list[str] = [] + for raw in raw_flags or (): + values.extend(shlex.split(str(raw))) + return tuple(values) + + +def _prik_args(args) -> tuple[str, ...]: + """Keep generation-affecting CLI options in the generated CMake call.""" + result: list[str] = [] + + def add_option(option: str, value: object | None = None) -> None: + if value is None: + result.append(option) + else: + result.append(f"{option}={value}") + + if args.compiler: + add_option("--compiler", args.compiler) + for option, values in ( + ("--define", args.defines), + ("--undef", args.undefs), + ("--compiler-arg", args.compiler_args), + ("--public-include", args.public_includes), + ("--private-include", args.private_includes), + ): + for value in values or (): + add_option(option, value) + for option, value in ( + ("--preprocessor-adapter", args.preprocessor_adapter), + ("--preprocess-template", args.preprocess_template), + ("--std", args.std), + ("--compile-commands", args.compile_commands), + ): + if value: + add_option(option, value) + if args.include_exposure != "reachable-project": + add_option("--include-exposure", args.include_exposure) + for option, values in ( + ("--wrapper-fortran-flags", args.wrapper_fortran_flags), + ("--wrapper-c-flags", args.wrapper_c_flags), + ("--collision-adapter", args.collision_adapters), + ): + if values: + result.extend(f"{option}={value}" for value in values) + for option, enabled in ( + ("--strict-wrapper-names", args.strict_wrapper_names), + ("--assume-intent-in-scalars", args.assume_intent_in_scalars), + ("--wrapper-compiler-debug", args.wrapper_compiler_debug), + ("--collision-adapter-all", args.collision_adapter_all), + ("--positional-only", args.positional_only), + ("--no-standard-logicals", not args.standard_logicals), + ): + if enabled: + add_option(option) + return tuple(result) + + +def _link_values( + args, + *, + base: Path, + native_link_items: Iterable[dict[str, object]], +) -> tuple[tuple[str, ...], tuple[str, ...]]: + libraries = [str(Path(path).resolve()) for path in (args.native_objects or ())] + for item in native_link_items: + kind = item["kind"] + if kind in {"object", "archive", "shared_library"}: + libraries.append(str(Path(str(item["path"])).resolve())) + elif kind == "named_library": + libraries.append(str(item["name"])) + elif kind == "linker_argument": + # CMake permits private linker flags among target_link_libraries + # items, which retains the explicit PRIK link-item order. + libraries.append(str(item["argument"])) + else: # pragma: no cover - the CLI normalizer rejects this first. + raise ValueError(f"Unsupported native link item kind: {kind!r}") + libraries.extend(_flag_values(args.native_libraries)) + options: list[str] = [f"-L{Path(path).resolve()}" for path in (args.native_library_dirs or ())] + return tuple( + _relative_path(Path(value), base) if Path(value).is_absolute() else value for value in libraries + ), tuple(options) + + +def _module_inputs(*, paths: Iterable[str | Path], args) -> _CMakeModuleInputs: + input_paths = _absolute_paths(paths) + if not input_paths: + raise ValueError("CMake generation requires at least one input") + if any(path.is_dir() for path in input_paths): + raise ValueError("generate --cmake expects source files or one semantic .pyi contract, not directories") + + contract = input_paths[0] if input_paths[0].suffix.lower() == ".pyi" else None + if contract is not None and len(input_paths) != 1: + raise ValueError("generate --cmake accepts one semantic .pyi contract") + if contract is not None: + default_module_name = contract.parent.name if contract.name == "__init__.pyi" else contract.stem + else: + default_module_name = input_paths[0].stem + module_name = args.module_name or default_module_name + if not module_name.isascii() or not module_name.isidentifier(): + raise ValueError(f"CMake generation requires a valid ASCII Python/C module name: {module_name!r}") + + native_fortran = _absolute_paths(args.native_fortran_sources or ()) + native_c = _absolute_paths(args.native_c_sources or ()) + if args.no_compile_input_sources and not native_fortran and not native_c: + raise ValueError("generate --cmake --no-compile-input-sources requires native implementation sources") + return _CMakeModuleInputs( + module_name=module_name, + semantic_sources=input_paths, + contract=contract, + native_fortran=native_fortran, + native_c=native_c, + ) + + +def _project_preamble(*, module_name: str, languages: str) -> list[str]: + return [ + "cmake_minimum_required(VERSION 3.20)", + "", + f"project({module_name} LANGUAGES {languages})", + "", + "find_package(", + " Python", + " COMPONENTS Interpreter Development.Module", + " REQUIRED", + ")", + "", + "execute_process(", + ' COMMAND "${Python_EXECUTABLE}" -c "from prik.cmake import cmake_module_dir; print(cmake_module_dir().as_posix())"', + " RESULT_VARIABLE PRIK_CMAKE_MODULE_RESULT", + " OUTPUT_VARIABLE PRIK_CMAKE_MODULE_DIR", + " ERROR_VARIABLE PRIK_CMAKE_MODULE_ERROR", + " OUTPUT_STRIP_TRAILING_WHITESPACE", + ")", + "if(NOT PRIK_CMAKE_MODULE_RESULT EQUAL 0)", + ' message(FATAL_ERROR "Cannot locate UsePRIK.cmake: ${PRIK_CMAKE_MODULE_ERROR}")', + "endif()", + 'list(APPEND CMAKE_MODULE_PATH "${PRIK_CMAKE_MODULE_DIR}")', + "include(UsePRIK)", + "", + "prik_add_module(", + f" {module_name}", + ] + + +def _append_source_declarations( + lines: list[str], + inputs: _CMakeModuleInputs, + *, + language: str, + no_compile_input_sources: bool, + base: Path, +) -> None: + if no_compile_input_sources: + lines.append(" NO_COMPILE_INPUT_SOURCES") + if inputs.contract is not None: + lines.append(f" CONTRACT {_cmake_string(_relative_path(inputs.contract, base))}") + elif not no_compile_input_sources and not inputs.native_fortran and not inputs.native_c: + keyword = "C_SOURCES" if language == "c" else "FORTRAN_SOURCES" + _append_block(lines, keyword, inputs.semantic_sources, base=base) + return + else: + _append_block(lines, "SOURCES", inputs.semantic_sources, base=base) + _append_block(lines, "FORTRAN_SOURCES", inputs.native_fortran, base=base) + _append_block(lines, "C_SOURCES", inputs.native_c, base=base) + + +def _append_build_options( + lines: list[str], + *, + args, + project_dir: Path, + native_link_items: Iterable[dict[str, object]], +) -> None: + _append_block(lines, "INCLUDE_DIRS", _absolute_paths(args.include_dirs or ()), base=project_dir) + fortran_flags = (*_flag_values(args.native_compile_flags), *_flag_values(args.wrapper_fortran_flags)) + c_flags = (*_flag_values(args.native_c_compile_flags), *_flag_values(args.wrapper_c_flags)) + libraries, link_options = _link_values(args, base=project_dir, native_link_items=native_link_items) + if args.lto: + fortran_flags = (*fortran_flags, "-flto") + c_flags = (*c_flags, "-flto") + link_options = (*link_options, "-flto") + _append_values(lines, "FORTRAN_FLAGS", fortran_flags) + _append_values(lines, "C_FLAGS", c_flags) + _append_values(lines, "LINK_LIBRARIES", libraries) + _append_values(lines, "LINK_OPTIONS", link_options) + _append_values(lines, "PRIK_ARGS", _prik_args(args)) + + +def write_cmake_project( + *, + paths: Iterable[str | Path], + output_dir: str | Path, + language: str, + args, + native_link_items: Iterable[dict[str, object]] = (), +) -> CMakeProjectResult: + """Write a readable standalone project backed by ``UsePRIK.cmake``.""" + project_dir = Path(output_dir).resolve() + project_dir.mkdir(parents=True, exist_ok=True) + inputs = _module_inputs(paths=paths, args=args) + cmake_module_dir() + project_languages = "C Fortran" if language == "fortran" or inputs.native_fortran else "C" + lines = _project_preamble(module_name=inputs.module_name, languages=project_languages) + _append_source_declarations( + lines, + inputs, + language=language, + no_compile_input_sources=args.no_compile_input_sources, + base=project_dir, + ) + _append_build_options(lines, args=args, project_dir=project_dir, native_link_items=native_link_items) + lines.append(")") + + cmake_lists = project_dir / "CMakeLists.txt" + cmake_lists.write_text("\n".join(lines) + "\n", encoding="utf-8") + return CMakeProjectResult(module_name=inputs.module_name, output_dir=project_dir, cmake_lists=cmake_lists) diff --git a/prik/compiler/native_support.py b/prik/compiler/native_support.py index 144e51bf0..a9fe7a93e 100644 --- a/prik/compiler/native_support.py +++ b/prik/compiler/native_support.py @@ -29,12 +29,21 @@ def _numpy_version_header() -> str: return header +def native_support_output_paths(imports, *, prik_dirpath) -> tuple[Path, ...]: + """Return build-relevant files written for requested native support.""" + if not any(name == _NATIVE_SUPPORT_IMPORT or name.startswith(f"{_NATIVE_SUPPORT_IMPORT}/") for name in imports): + return () + destination = Path(prik_dirpath) / _NATIVE_SUPPORT_IMPORT + return destination / "prik_binding.h", destination / "numpy_version.h" + + def install_native_support(imports, *, prik_dirpath, verbose: bool | int = False) -> None: """Write header-only native binding support when a generated binding imports it.""" - if not any(name == _NATIVE_SUPPORT_IMPORT or name.startswith(f"{_NATIVE_SUPPORT_IMPORT}/") for name in imports): + outputs = native_support_output_paths(imports, prik_dirpath=prik_dirpath) + if not outputs: return - destination = Path(prik_dirpath) / _NATIVE_SUPPORT_IMPORT + destination = outputs[0].parent if verbose: print(f">> Write native support: {destination}") with FileLock(str(destination.with_suffix(".lock"))): diff --git a/prik/pipeline/build.py b/prik/pipeline/build.py index b8becc7a3..b3287f3ab 100644 --- a/prik/pipeline/build.py +++ b/prik/pipeline/build.py @@ -34,7 +34,7 @@ from prik.compiler.objects import ObjectFile from prik.compiler.compilers import Compiler, get_condaless_search_path -from prik.compiler.native_support import install_native_support +from prik.compiler.native_support import install_native_support, native_support_output_paths from prik.parsers.c import parse_c_file from prik.parsers.c.cli import attach_preprocessing_recipe from prik.parsers.fortran.parser import parse_fortran_project @@ -1093,19 +1093,48 @@ def _build_generated_wrapper_extension( compiler: Compiler | None = None, compile_jobs: int | None = None, verbose: bool | int = False, + _plan_only: bool = False, ) -> WrapperBuildResult: - """Write, compile, and link one complete generated wrapper.""" - # Materialize the canonical wrapper output before creating compiler inputs. + """Write, compile, and link one complete generated wrapper. + + ``_plan_only`` preserves the completed wrapper and native plans while + returning their deterministic generated-source paths without writing, + compiling, or linking. Build integrations use that narrow query to declare + their own dependency graph before requesting normal source generation. + """ + # Freeze the canonical wrapper before selecting materialization or planning. rendered.freeze() output_path = Path(output_dir) - output_path.mkdir(parents=True, exist_ok=True) shared_output_path = Path(shared_library_output_dir) if shared_library_output_dir is not None else output_path + resolved_native_build_plan = native_build_plan or NativeBuildPlan() + if _plan_only: + generated_sources = tuple(_generated_source_output_path(output_path, path) for path in rendered.generated_files) + native_support_imports = _generated_wrapper_native_support_imports(rendered.native_support_keys) + native_support_files = native_support_output_paths( + native_support_imports, + prik_dirpath=output_path, + ) + return WrapperBuildResult( + sources=tuple(Path(source) for source in sources), + module_name=rendered.module_name, + output_dir=output_path, + # This path is informational in a plan-only result; CMake owns the + # real extension suffix and output location. + shared_library=shared_output_path / f"{rendered.module_name}.so", + build_makefile=None, + compiled=False, + generated_sources=generated_sources, + generated_files=(*generated_sources, *native_support_files), + native_build_plan=resolved_native_build_plan, + native_generated_code_groups=rendered.native_generated_code_groups, + ) + + output_path.mkdir(parents=True, exist_ok=True) shared_output_path.mkdir(parents=True, exist_ok=True) _write_generated_wrapper_sources(rendered, output_path, verbose=verbose) # Prepare generated-object inputs and their native support files. compiler = compiler or _new_compiler() - resolved_native_build_plan = native_build_plan or NativeBuildPlan() bridge_objects, binding_objects = _generated_wrapper_object_stages( rendered, output_path, @@ -3294,6 +3323,7 @@ def build_fortran_extension( wrapper_c_flags: Iterable[str] | None = None, standard_logicals: bool = True, _on_total_build_time: Callable[[float], None] | None = None, + _plan_only: bool = False, ) -> WrapperBuildResult: """Build a Python extension from one or more Fortran source files. @@ -3395,7 +3425,8 @@ def build_fortran_extension( # 1. Collect the source and native implementation inputs. source_paths = _source_paths(sources) output_path, shared_library_output_path = _wrapper_output_paths(output_dir) - output_path.mkdir(parents=True, exist_ok=True) + if not _plan_only: + output_path.mkdir(parents=True, exist_ok=True) preprocessing = preprocessing or _default_preprocessing_config() supplemental_source_paths = tuple(Path(path) for path in (native_fortran_sources or ())) input_implementation_paths = source_paths if compile_input_sources else () @@ -3437,7 +3468,7 @@ def build_fortran_extension( collision_adapter_all=collision_adapter_all, positional_only=positional_only, ) - contract_files = _write_build_contract_package(source_modules, output_path, verbose=verbose) + contract_files = () if _plan_only else _write_build_contract_package(source_modules, output_path, verbose=verbose) # 4. Prepare native compilation, dependency batches, and link inputs. wrapper_fortran_flags = _compiler_flags(wrapper_fortran_flags) @@ -3466,6 +3497,7 @@ def build_fortran_extension( compiler=compiler, compile_jobs=1 if generation_only else compile_jobs, verbose=verbose, + _plan_only=_plan_only, ) result = _finalize_build_mode( result, @@ -3517,6 +3549,7 @@ def build_c_extension( wrapper_c_flags: Iterable[str] | None = None, standard_logicals: bool = True, _on_total_build_time: Callable[[float], None] | None = None, + _plan_only: bool = False, ) -> WrapperBuildResult: """Build a direct-only C extension from explicit C implementation sources. @@ -3619,12 +3652,14 @@ def build_c_extension( collision_adapter_all=collision_adapter_all, positional_only=positional_only, ) - output_path.mkdir(parents=True, exist_ok=True) - contract_files = _write_build_contract_package( - tuple(_wrapped_c_translation_unit(module) for module in source_modules), - output_path, - verbose=verbose, - ) + contract_files = () + if not _plan_only: + output_path.mkdir(parents=True, exist_ok=True) + contract_files = _write_build_contract_package( + tuple(_wrapped_c_translation_unit(module) for module in source_modules), + output_path, + verbose=verbose, + ) native_source_objects, native_build_plan = _prepare_native_build_plan(native_inputs, output_path=output_path) wrapper_fortran_flags = _compiler_flags(wrapper_fortran_flags) wrapper_c_flags = _compiler_flags(wrapper_c_flags) @@ -3642,6 +3677,7 @@ def build_c_extension( compiler=compiler, compile_jobs=1 if generation_only else compile_jobs, verbose=verbose, + _plan_only=_plan_only, ) result = _finalize_build_mode( result, @@ -3691,6 +3727,7 @@ def build_pyi_extension( wrapper_c_flags: Iterable[str] | None = None, standard_logicals: bool = True, _on_total_build_time: Callable[[float], None] | None = None, + _plan_only: bool = False, ) -> WrapperBuildResult: """Build a Python extension from an editable semantic ``.pyi`` contract. @@ -3823,7 +3860,8 @@ def build_pyi_extension( collision_adapter_all=collision_adapter_all, positional_only=positional_only, ) - output_path.mkdir(parents=True, exist_ok=True) + if not _plan_only: + output_path.mkdir(parents=True, exist_ok=True) # 3. Prepare native compilation and link inputs before selecting the compiler. native_source_objects, native_build_plan = _prepare_native_build_plan(native_inputs, output_path=output_path) @@ -3853,6 +3891,7 @@ def build_pyi_extension( compiler=compiler, compile_jobs=1 if generation_only else compile_jobs, verbose=verbose, + _plan_only=_plan_only, ) result = _with_pyi_manifest( result, diff --git a/pyproject.toml b/pyproject.toml index 6ad6dd8ef..397ecce70 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -87,6 +87,9 @@ include = ["prik*"] [tool.setuptools.package-data] "prik.runtime.native_support" = ["*.h", "LICENSE"] +[tool.setuptools.data-files] +"share/prik/cmake" = ["cmake/UsePRIK.cmake"] + [project.scripts] prik = "prik.cli:main" diff --git a/tests/fortran/infrastructure/building/end_to_end/test_cmake_builds.py b/tests/fortran/infrastructure/building/end_to_end/test_cmake_builds.py new file mode 100644 index 000000000..509684c21 --- /dev/null +++ b/tests/fortran/infrastructure/building/end_to_end/test_cmake_builds.py @@ -0,0 +1,647 @@ +"""CMake integration tests for the PRIK wrapper-generation boundary.""" + +from __future__ import annotations + +import importlib +import json +import os +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile + +import numpy as np +import pytest + + +REPOSITORY_ROOT = Path(__file__).resolve().parents[5] +USE_PRIK_DIR = REPOSITORY_ROOT / "cmake" +BRIDGE_CONTRACT = ( + REPOSITORY_ROOT + / "tests" + / "fortran" + / "functions" + / "end_to_end" + / "fixtures" + / "contracts" + / "free_external" + / "__init__.pyi" +) +BRIDGE_NATIVE = ( + REPOSITORY_ROOT / "tests" / "fortran" / "functions" / "end_to_end" / "fixtures" / "native" / "free_external.f90" +) + + +def _environment() -> dict[str, str]: + environment = os.environ.copy() + existing = environment.get("PYTHONPATH") + environment["PYTHONPATH"] = str(REPOSITORY_ROOT) + (os.pathsep + existing if existing else "") + return environment + + +def _run(command: list[str], *, cwd: Path | None = None) -> subprocess.CompletedProcess[str]: + result = subprocess.run(command, cwd=cwd, env=_environment(), capture_output=True, text=True) + if result.returncode: + raise AssertionError( + f"Command failed ({result.returncode}): {' '.join(command)}\n" + f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}" + ) + return result + + +def _configure_and_build( + project: Path, + build: Path, + *, + language: str, + use_ninja: bool = True, + build_project: bool = True, +) -> None: + command = ["cmake", "-S", str(project), "-B", str(build)] + if use_ninja and shutil.which("ninja"): + command.extend(("-G", "Ninja")) + command.append(f"-DCMAKE_C_COMPILER={shutil.which('gcc')}") + if language == "fortran": + command.append(f"-DCMAKE_Fortran_COMPILER={shutil.which('gfortran')}") + _run(command) + if build_project: + _run(["cmake", "--build", str(build), "-j2"]) + + +def _import_extension(module_name: str, build: Path): + artifacts = tuple(build.rglob(f"{module_name}*.so")) + assert artifacts, f"no CMake extension artifact in {build}" + sys.modules.pop(module_name, None) + sys.path.insert(0, str(artifacts[0].parent)) + try: + return importlib.import_module(module_name) + finally: + sys.path.remove(str(artifacts[0].parent)) + + +def _write_project(project: Path, body: str, *, languages: str = "C Fortran") -> None: + project.mkdir(parents=True, exist_ok=True) + (project / "CMakeLists.txt").write_text( + f"""cmake_minimum_required(VERSION 3.20) +project(cmake_test LANGUAGES {languages}) +find_package(Python COMPONENTS Interpreter Development.Module REQUIRED) +list(APPEND CMAKE_MODULE_PATH "{USE_PRIK_DIR.as_posix()}") +include(UsePRIK) +{body} +""", + encoding="utf-8", + ) + + +def _cmake_finds_blas() -> bool: + if shutil.which("cmake") is None or shutil.which("gfortran") is None: + return False + with tempfile.TemporaryDirectory(prefix="prik-cmake-blas-") as probe_directory: + result = subprocess.run( + [ + "cmake", + "--find-package", + "-DNAME=BLAS", + "-DCOMPILER_ID=GNU", + "-DLANGUAGE=Fortran", + "-DMODE=EXIST", + ], + cwd=probe_directory, + capture_output=True, + text=True, + ) + return result.returncode == 0 + + +@pytest.mark.fortran_end_to_end +@pytest.mark.skipif( + shutil.which("cmake") is None or shutil.which("gfortran") is None or shutil.which("gcc") is None, + reason="CMake, gfortran, and gcc are required", +) +def test_use_prik_cmake_builds_source_first_fortran_module(tmp_path: Path): + project = tmp_path / "user project with spaces" + project.mkdir() + (project / "square.f90").write_text( + """real(8) function square(x) result(y) + real(8), intent(in) :: x + y = x * x +end function square +""", + encoding="utf-8", + ) + _write_project( + project, + """prik_add_module( + square + SOURCES square.f90 + FORTRAN_FLAGS -O0 +) +""", + ) + build = project / "build" + _configure_and_build(project, build, language="fortran", build_project=False) + assert not tuple((build / "prik" / "square").glob("*_wrapper.*")) + _run(["cmake", "--build", str(build), "-j2"]) + native_support_header = build / "prik" / "square" / "binding_support" / "prik_binding.h" + assert native_support_header.is_file() + native_support_header.unlink() + _run(["cmake", "--build", str(build), "-j2"]) + assert native_support_header.is_file() + module = _import_extension("square", build) + assert module.square(np.float64(3.0)) == np.float64(9.0) + + +@pytest.mark.fortran_end_to_end +@pytest.mark.skipif( + shutil.which("cmake") is None or shutil.which("gfortran") is None or shutil.which("gcc") is None, + reason="CMake, gfortran, and gcc are required", +) +def test_use_prik_cmake_builds_a_fortran_module(tmp_path: Path): + project = tmp_path / "fortran module" + project.mkdir() + (project / "math_mod.f90").write_text( + """module math_mod +contains + real(8) function add(a, b) result(c) + real(8), intent(in) :: a, b + c = a + b + end function add +end module math_mod +""", + encoding="utf-8", + ) + _write_project( + project, + """prik_add_module( + math_mod + SOURCES math_mod.f90 +) +""", + ) + build = project / "build" + _configure_and_build(project, build, language="fortran") + module = _import_extension("math_mod", build) + assert module.math_mod.add(np.float64(2.0), np.float64(3.0)) == np.float64(5.0) + + +@pytest.mark.fortran_end_to_end +@pytest.mark.skipif( + shutil.which("cmake") is None or shutil.which("gfortran") is None or shutil.which("gcc") is None, + reason="CMake, gfortran, and gcc are required", +) +def test_generate_cmake_builds_standalone_project_in_a_path_with_spaces(tmp_path: Path): + source = tmp_path / "standalone.f90" + source.write_text( + """real(8) function square(x) result(y) + real(8), intent(in) :: x + y = x * x +end function square +""", + encoding="utf-8", + ) + project = tmp_path / "generated project with spaces" + generated = _run( + [ + sys.executable, + "-m", + "prik", + "generate", + "--cmake", + str(source), + "--module-name", + "generated_square", + "--out-dir", + str(project), + "--json", + ] + ) + assert Path(json.loads(generated.stdout)["cmake_project"]) == project / "CMakeLists.txt" + cmake_lists = (project / "CMakeLists.txt").read_text(encoding="utf-8") + assert "include(UsePRIK)" in cmake_lists + assert "prik_add_module(\n generated_square" in cmake_lists + build = project / "cmake-build" + _configure_and_build(project, build, language="fortran", use_ninja=False) + module = _import_extension("generated_square", build) + assert module.square(np.float64(4.0)) == np.float64(16.0) + + +@pytest.mark.fortran_end_to_end +def test_generate_cmake_preserves_ordered_native_link_items(tmp_path: Path): + source = tmp_path / "ordered.f90" + source.write_text("subroutine ordered()\nend subroutine ordered\n", encoding="utf-8") + archive = tmp_path / "libordered.a" + archive.touch() + project = tmp_path / "ordered project" + _run( + [ + sys.executable, + "-m", + "prik", + "generate", + "--cmake", + str(source), + "--native-link-item", + "arg:-Wl,--start-group", + f"archive:{archive}", + "library:ordered", + "arg:-Wl,--end-group", + "--out-dir", + str(project), + ] + ) + cmake_lists = (project / "CMakeLists.txt").read_text(encoding="utf-8") + ordered_items = ( + '"-Wl,--start-group"', + f'"{Path(os.path.relpath(archive, project)).as_posix()}"', + '"ordered"', + '"-Wl,--end-group"', + ) + positions = tuple(cmake_lists.index(item) for item in ordered_items) + assert positions == tuple(sorted(positions)) + + +@pytest.mark.fortran_end_to_end +@pytest.mark.skipif( + shutil.which("cmake") is None or shutil.which("gfortran") is None or shutil.which("gcc") is None, + reason="CMake, gfortran, and gcc are required", +) +def test_generate_cmake_keeps_supplemental_c_sources_out_of_the_python_api(tmp_path: Path): + source = tmp_path / "mixed.f90" + source.write_text( + """real(8) function add_one(value) result(result) + use iso_c_binding, only: c_double + real(8), intent(in) :: value + interface + function native_add_one(input) bind(C, name="native_add_one") result(output) + import c_double + real(c_double), value :: input + real(c_double) :: output + end function native_add_one + end interface + result = native_add_one(value) +end function add_one +""", + encoding="utf-8", + ) + native_c = tmp_path / "native.c" + native_c.write_text("double native_add_one(double value) { return value + 1.0; }\n", encoding="utf-8") + project = tmp_path / "generated mixed project" + _run( + [ + sys.executable, + "-m", + "prik", + "generate", + "--cmake", + str(source), + "--module-name", + "mixed_extension", + "--native-c-sources", + str(native_c), + "--out-dir", + str(project), + ] + ) + cmake_lists = (project / "CMakeLists.txt").read_text(encoding="utf-8") + assert "SOURCES" in cmake_lists + assert "C_SOURCES" in cmake_lists + build = project / "cmake-build" + _configure_and_build(project, build, language="fortran") + module = _import_extension("mixed_extension", build) + assert module.add_one(np.float64(4.0)) == np.float64(5.0) + + +@pytest.mark.fortran_end_to_end +@pytest.mark.skipif( + shutil.which("cmake") is None or shutil.which("gfortran") is None or shutil.which("gcc") is None, + reason="CMake, gfortran, and gcc are required", +) +def test_use_prik_cmake_contract_dependency_regenerates_wrapper(tmp_path: Path): + native = tmp_path / "contract native.f90" + native.write_text( + """real(8) function square(x) result(y) + real(8), intent(in) :: x + y = x * x +end function square +""", + encoding="utf-8", + ) + contracts = tmp_path / "contracts" + _run([sys.executable, "-m", "prik", "generate", "--pyi", str(native), "--out", str(contracts)]) + contract_dir = tmp_path / "contract_example" + contract_dir.mkdir() + contract = contract_dir / "__init__.pyi" + contract_leaf = contract_dir / "marker.pyi" + contract.write_text( + (contracts / "__init__.pyi").read_text(encoding="utf-8") + "\nfrom . import marker\n", + encoding="utf-8", + ) + contract_leaf.write_text("# included contract dependency\n", encoding="utf-8") + project = tmp_path / "contract project" + _write_project( + project, + f"""prik_add_module( + contract_example + CONTRACT "{contract.as_posix()}" + FORTRAN_SOURCES "{native.as_posix()}" +) +""", + ) + build = project / "build" + _configure_and_build(project, build, language="fortran") + module = _import_extension("contract_example", build) + assert module.square(np.float64(3.0)) == np.float64(9.0) + native.write_text(native.read_text(encoding="utf-8").replace("y = x * x", "y = x * x + 1.0"), encoding="utf-8") + native_rebuild = _run(["cmake", "--build", str(build), "--verbose", "-j2"]) + native_output = native_rebuild.stdout + native_rebuild.stderr + assert "contract_native.f90" in native_output + assert "Generate PRIK wrapper sources for contract_example" not in native_output + contract_leaf.write_text(contract_leaf.read_text(encoding="utf-8") + "\n# contract changed\n", encoding="utf-8") + contract_rebuild = _run(["cmake", "--build", str(build), "-j2"]) + contract_output = contract_rebuild.stdout + contract_rebuild.stderr + assert "Generate PRIK wrapper sources for contract_example" in contract_output + + +@pytest.mark.fortran_end_to_end +@pytest.mark.skipif( + shutil.which("cmake") is None or shutil.which("gfortran") is None or shutil.which("gcc") is None, + reason="CMake, gfortran, and gcc are required", +) +def test_use_prik_cmake_compiles_generated_fortran_bridge_and_binding(tmp_path: Path): + project = tmp_path / "bridge project" + _write_project( + project, + f"""prik_add_module( + free_external + CONTRACT "{BRIDGE_CONTRACT.as_posix()}" + FORTRAN_SOURCES "{BRIDGE_NATIVE.as_posix()}" +) +""", + ) + build = project / "build" + _configure_and_build(project, build, language="fortran") + generated = build / "prik" / "free_external" + assert tuple(generated.glob("*.f90")), "the generated Fortran bridge is missing" + assert tuple(generated.glob("*.c")), "the generated C binding is missing" + module = _import_extension("free_external", build) + assert module.free_square(np.int32(6)) == np.int32(36) + + +@pytest.mark.fortran_end_to_end +@pytest.mark.skipif( + shutil.which("cmake") is None or shutil.which("gfortran") is None or shutil.which("gcc") is None, + reason="CMake, gfortran, and gcc are required", +) +def test_use_prik_cmake_reconfigures_when_source_adds_a_fortran_bridge(tmp_path: Path): + project = tmp_path / "routing project" + project.mkdir() + source = project / "routing.f90" + source.write_text( + """integer(c_int) function standalone_direct(value) bind(C, name="standalone_direct_symbol") result(output) + use iso_c_binding + integer(c_int), value, intent(in) :: value + + output = value + 2_c_int +end function standalone_direct +""", + encoding="utf-8", + ) + _write_project( + project, + """prik_add_module( + routing + SOURCES routing.f90 +) +""", + ) + build = project / "build" + _configure_and_build(project, build, language="fortran") + generated = build / "prik" / "routing" + assert not tuple(generated.glob("*.f90")) + module = _import_extension("routing", build) + assert module.standalone_direct(np.int32(4)) == np.int32(6) + + source.write_text( + """integer(c_int) function standalone_direct(value) bind(C, name="standalone_mixed_direct") result(output) + use iso_c_binding + integer(c_int), value, intent(in) :: value + + output = value + 2_c_int +end function standalone_direct + +integer(c_int) function standalone_adapted(value) result(output) + use iso_c_binding + integer(c_int), intent(in) :: value + + output = value + 3_c_int +end function standalone_adapted +""", + encoding="utf-8", + ) + _run(["cmake", "--build", str(build), "-j2"]) + assert tuple(generated.glob("*.f90")), "CMake did not reconfigure the generated bridge source set" + + +@pytest.mark.fortran_end_to_end +@pytest.mark.skipif(shutil.which("cmake") is None or shutil.which("gcc") is None, reason="CMake and gcc are required") +def test_use_prik_cmake_builds_c_source_with_include_directory_and_flag(tmp_path: Path): + project = tmp_path / "c project with spaces" + include_dir = project / "include files" + include_dir.mkdir(parents=True) + (include_dir / "cmath_api.h").write_text("double c_add(double, double);\n", encoding="utf-8") + (project / "cexample.c").write_text( + '#include "cmath_api.h"\n#ifdef PRIK_CMAKE_TEST_FLAG\ndouble c_add(double a, double b) { return a + b + 1.0; }\n#else\ndouble c_add(double a, double b) { return a + b; }\n#endif\n', + encoding="utf-8", + ) + _write_project( + project, + f"""prik_add_module( + cexample + C_SOURCES cexample.c + INCLUDE_DIRS "{include_dir.as_posix()}" + C_FLAGS -DPRIK_CMAKE_TEST_FLAG + PRIK_ARGS --define PRIK_CMAKE_TEST_FLAG +) +""", + languages="C", + ) + build = project / "build" + _configure_and_build(project, build, language="c") + module = _import_extension("cexample", build) + assert module.c_add(np.float64(2.0), np.float64(3.0)) == np.float64(6.0) + + +@pytest.mark.fortran_end_to_end +@pytest.mark.skipif( + shutil.which("cmake") is None or shutil.which("gfortran") is None or shutil.which("gcc") is None, + reason="CMake, gfortran, and gcc are required", +) +@pytest.mark.parametrize( + ("module_arguments", "expected_error_fragment"), + ( + ( + "SOURCES source.f90\n PRIK_ARGS --module-name hijacked", + "PRIK_ARGS cannot override prik_add_module build ownership", + ), + ( + "FORTRAN_SOURCES source.f90\n NO_COMPILE_INPUT_SOURCES", + "NO_COMPILE_INPUT_SOURCES", + ), + ( + "SOURCES source.f90\n NO_COMPILE_INPUT_SOURCES", + "requires native implementation sources", + ), + ), +) +def test_use_prik_cmake_rejects_invalid_generation_ownership( + tmp_path: Path, + module_arguments: str, + expected_error_fragment: str, +): + project = tmp_path / "reserved args" + project.mkdir() + (project / "source.f90").write_text("subroutine source()\nend subroutine source\n", encoding="utf-8") + _write_project( + project, + f"""prik_add_module( + reserved_args + {module_arguments} +) +""", + ) + result = subprocess.run( + ["cmake", "-S", str(project), "-B", str(project / "build")], + env=_environment(), + capture_output=True, + text=True, + ) + assert result.returncode != 0 + assert expected_error_fragment in result.stdout + result.stderr + + +@pytest.mark.fortran_end_to_end +@pytest.mark.skipif( + shutil.which("cmake") is None or shutil.which("gfortran") is None or shutil.which("gcc") is None, + reason="CMake, gfortran, and gcc are required", +) +def test_use_prik_cmake_links_a_normal_fortran_library_target(tmp_path: Path): + project = tmp_path / "external target" + project.mkdir() + (project / "native_math.f90").write_text( + "real(8) function native_add(x, y) result(z)\n" + " real(8), intent(in) :: x, y\n" + " z = x + y\n" + "end function native_add\n", + encoding="utf-8", + ) + (project / "wrapper.f90").write_text( + "real(8) function call_native(x, y) result(z)\n" + " real(8), intent(in) :: x, y\n" + " interface\n" + " function native_add(a, b) result(c)\n" + " real(8), intent(in) :: a, b\n" + " real(8) :: c\n" + " end function native_add\n" + " end interface\n" + " z = native_add(x, y)\n" + "end function call_native\n", + encoding="utf-8", + ) + _write_project( + project, + """add_library(native_math STATIC native_math.f90) +prik_add_module( + external_target + SOURCES wrapper.f90 + LINK_LIBRARIES native_math +) +""", + ) + build = project / "build" + _configure_and_build(project, build, language="fortran") + module = _import_extension("external_target", build) + assert module.call_native(np.float64(2.0), np.float64(3.0)) == np.float64(5.0) + + +@pytest.mark.fortran_end_to_end +@pytest.mark.skipif( + shutil.which("cmake") is None or shutil.which("gfortran") is None or shutil.which("gcc") is None, + reason="CMake, gfortran, and gcc are required", +) +def test_generate_cmake_can_keep_semantic_sources_out_of_native_compilation(tmp_path: Path): + project = tmp_path / "separate implementation" + project.mkdir() + (project / "interface.f90").write_text( + """real(8) function square(value) result(result) + real(8), intent(in) :: value + result = value * value +end function square +""", + encoding="utf-8", + ) + (project / "implementation.f90").write_text( + """real(8) function square(value) result(result) + real(8), intent(in) :: value + result = value * value + 1.0 +end function square +""", + encoding="utf-8", + ) + _run( + [ + sys.executable, + "-m", + "prik", + "generate", + "--cmake", + "--module-name", + "separate_implementation", + "--no-compile-input-sources", + str(project / "interface.f90"), + "--native-fortran-sources", + str(project / "implementation.f90"), + "--out-dir", + str(project), + ] + ) + assert "NO_COMPILE_INPUT_SOURCES" in (project / "CMakeLists.txt").read_text(encoding="utf-8") + build = project / "build" + _configure_and_build(project, build, language="fortran") + module = _import_extension("separate_implementation", build) + assert module.square(np.float64(3.0)) == np.float64(10.0) + + +@pytest.mark.fortran_end_to_end +@pytest.mark.skipif(not _cmake_finds_blas(), reason="CMake cannot discover a Fortran BLAS implementation") +def test_use_prik_cmake_links_a_cmake_discovered_blas_target(tmp_path: Path): + project = tmp_path / "blas target" + (project / "blas_example.f90").parent.mkdir(parents=True) + (project / "blas_example.f90").write_text( + "real(8) function blas_dot(x, y) result(value)\n" + " real(8), intent(in) :: x(2), y(2)\n" + " real(8) ddot\n" + " external ddot\n" + " value = ddot(2, x, 1, y, 1)\n" + "end function blas_dot\n", + encoding="utf-8", + ) + _write_project( + project, + """find_package(BLAS REQUIRED) +if(TARGET BLAS::BLAS) + set(PRIK_TEST_BLAS_TARGET BLAS::BLAS) +else() + set(PRIK_TEST_BLAS_TARGET ${BLAS_LIBRARIES}) +endif() +prik_add_module( + blas_example + FORTRAN_SOURCES blas_example.f90 + LINK_LIBRARIES ${PRIK_TEST_BLAS_TARGET} +) +""", + ) + build = project / "build" + _configure_and_build(project, build, language="fortran") + module = _import_extension("blas_example", build) + assert module.blas_dot(np.array([1.0, 2.0]), np.array([3.0, 4.0])) == np.float64(11.0) diff --git a/tests/fortran/infrastructure/cli/pipeline/test_argument_contract.py b/tests/fortran/infrastructure/cli/pipeline/test_argument_contract.py index ff7a91d81..2aa899086 100644 --- a/tests/fortran/infrastructure/cli/pipeline/test_argument_contract.py +++ b/tests/fortran/infrastructure/cli/pipeline/test_argument_contract.py @@ -1,5 +1,6 @@ """Tests split by stable CLI argument-contract ownership.""" +import argparse import json from pathlib import Path import subprocess @@ -137,7 +138,7 @@ class extra(Opaque): ), ( {"out": "module", "makefile": True}, - "generate --sources/--makefile uses --out-dir, not --out", + "generate --sources/--makefile/--cmake uses --out-dir, not --out", ), ({"parse": True, "print_limit": -1}, "--print-limit must be >= 0"), ( @@ -746,7 +747,7 @@ def assert_group_order(help_text, *headings): assert "preprocessing and datatype measurement" in normalized_semantics_help assert "native and bridge compilation" not in normalized_semantics_help assert "default: gfortran; cc with --language c" in normalized_semantics_help - assert "(--pyi | --sources | --makefile)" in generate_help + assert "(--pyi | --sources | --makefile | --cmake)" in generate_help assert "INPUT [INPUT ...] [OPTIONS]" in generate_help assert "--build-manifest PATH [OVERRIDES]" in generate_help for heading in ( @@ -777,6 +778,7 @@ def assert_group_order(help_text, *headings): assert "--pyi" in generate_help assert "--sources" in generate_help assert "--makefile" in generate_help + assert "--cmake" in generate_help assert "Read an existing prik-build.json and regenerate wrapper artifacts" in normalized_generate_help assert "Compiler used for source analysis and wrapper build files" in normalized_generate_help assert "default: gfortran; cc with --language c" in normalized_generate_help @@ -917,7 +919,9 @@ def test_help_build_routes_to_the_full_default_build_help(): def test_help_build_exposes_every_supported_build_option(): parser = prik_cli._build_parser(["--help"]) help_text = parser.format_help() - option_strings = {option for action in parser._actions for option in action.option_strings} + option_strings = { + option for action in parser._actions if action.help != argparse.SUPPRESS for option in action.option_strings + } assert option_strings assert all(option in help_text for option in option_strings) @@ -935,7 +939,9 @@ def test_help_build_exposes_every_supported_build_option(): def test_subcommand_help_exposes_every_supported_option(parser_factory): parser = parser_factory(["--help"]) help_text = parser.format_help() - option_strings = {option for action in parser._actions for option in action.option_strings} + option_strings = { + option for action in parser._actions if action.help != argparse.SUPPRESS for option in action.option_strings + } assert option_strings assert all(option in help_text for option in option_strings) From fe3423338eaa7725dc5ec50f1e5fbe5e59706c31 Mon Sep 17 00:00:00 2001 From: said Date: Thu, 10 Sep 2026 17:32:46 +0100 Subject: [PATCH 2/5] codex: complete CMake build-plan integration --- .github/workflows/publish-to-pypi.yml | 2 + CHANGELOG.md | 7 +- cmake/UsePRIK.cmake | 169 ++++++- docs/developer/packages/pipeline.md | 8 + docs/user/guide/cmake.md | 53 ++- docs/user/reference/cli-commands.md | 9 +- docs/user/reference/configuration-files.md | 32 +- docs/user/reference/python-api.md | 2 +- prik/cli.py | 44 +- prik/cmake.py | 41 +- prik/compiler/compilers.py | 12 + prik/pipeline/build.py | 290 ++++++++++-- .../compiling/test_logical_interop_flags.py | 22 + .../building/end_to_end/test_cmake_builds.py | 413 +++++++++++++++++- .../pipeline/test_generated_wrapper_build.py | 21 + .../building/pipeline/test_pyi_build_modes.py | 2 +- .../cli/pipeline/test_argument_contract.py | 2 +- 17 files changed, 1012 insertions(+), 117 deletions(-) diff --git a/.github/workflows/publish-to-pypi.yml b/.github/workflows/publish-to-pypi.yml index bdd96ea4b..bc9d9f542 100644 --- a/.github/workflows/publish-to-pypi.yml +++ b/.github/workflows/publish-to-pypi.yml @@ -62,6 +62,8 @@ jobs: "$RUNNER_TEMP/prik-release-check/bin/prik" --version "$RUNNER_TEMP/prik-release-check/bin/python" -c \ 'import importlib.metadata as m, prik; assert prik.__version__ == m.version("prik")' + "$RUNNER_TEMP/prik-release-check/bin/python" -c \ + 'from prik.cmake import cmake_module_dir; assert (cmake_module_dir() / "UsePRIK.cmake").is_file()' "$RUNNER_TEMP/prik-release-check/bin/prik" --help "$RUNNER_TEMP/prik-release-check/bin/python" -m prik --help - name: Upload distributions diff --git a/CHANGELOG.md b/CHANGELOG.md index 9606ff6e7..16eff765a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,12 @@ release tags add a leading `v` to the package version. - Added CMake integration through the packaged `UsePRIK.cmake` helper and a `prik generate --cmake` standalone-project mode. CMake generates PRIK wrapper sources as build outputs and owns native compilation, linking, external - targets, and incremental rebuilds. + targets, and incremental rebuilds while preserving per-source flags, + compiler-required ABI options, preprocessing dependencies, and linker + language from PRIK's completed build plan. + +- `prik-build.json` schema 5 records generated/native compilation-unit ABI + flags and explicit native linker-language requirements. - Array handles support allocatable and pointer arguments, results, module variables, derived fields, optional arguments, and matching ordinary-array diff --git a/cmake/UsePRIK.cmake b/cmake/UsePRIK.cmake index 685b54cf3..a2f34805a 100644 --- a/cmake/UsePRIK.cmake +++ b/cmake/UsePRIK.cmake @@ -50,23 +50,36 @@ function(_prik_append_cli_flags command option flags) set(${command} "${_command}" PARENT_SCOPE) endfunction() -function(_prik_json_string_list output_variable json key) - string(JSON _prik_item_count ERROR_VARIABLE _prik_json_error LENGTH "${json}" "${key}") +function(_prik_json_string_list output_variable json) + set(_prik_json_path ${ARGN}) + string(JSON _prik_item_count ERROR_VARIABLE _prik_json_error LENGTH "${json}" ${_prik_json_path}) if(_prik_json_error) - message(FATAL_ERROR "PRIK did not return ${key}: ${_prik_json_error}") + message(FATAL_ERROR "PRIK did not return ${_prik_json_path}: ${_prik_json_error}") endif() set(_prik_items) if(_prik_item_count GREATER 0) math(EXPR _prik_last_item_index "${_prik_item_count} - 1") foreach(_prik_item_index RANGE 0 ${_prik_last_item_index}) - string(JSON _prik_item GET "${json}" "${key}" ${_prik_item_index}) + string(JSON _prik_item GET "${json}" ${_prik_json_path} ${_prik_item_index}) list(APPEND _prik_items "${_prik_item}") endforeach() endif() set(${output_variable} "${_prik_items}" PARENT_SCOPE) endfunction() +function(_prik_rebase_plan_path output_variable path source_root output_root) + file(RELATIVE_PATH _prik_relative_path "${source_root}" "${path}") + if(_prik_relative_path STREQUAL "") + set(_prik_rebased_path "${output_root}") + elseif(NOT _prik_relative_path MATCHES "^\\.\\.") + set(_prik_rebased_path "${output_root}/${_prik_relative_path}") + else() + set(_prik_rebased_path "${path}") + endif() + set(${output_variable} "${_prik_rebased_path}" PARENT_SCOPE) +endfunction() + function(_prik_rebase_generated_paths output_variable paths source_root output_root) set(_prik_rebased_paths) foreach(_prik_path IN LISTS ${paths}) @@ -79,9 +92,62 @@ function(_prik_rebase_generated_paths output_variable paths source_root output_r set(${output_variable} "${_prik_rebased_paths}" PARENT_SCOPE) endfunction() +function(_prik_apply_compilation_unit target json group index generated source_root output_root) + if(group STREQUAL "native") + set(_prik_unit_path native_build_plan compilation_units) + else() + set(_prik_unit_path generated_compilation_units) + endif() + string(JSON _prik_unit_source GET "${json}" ${_prik_unit_path} ${index} source) + string(JSON _prik_unit_language GET "${json}" ${_prik_unit_path} ${index} language) + if(generated) + _prik_rebase_plan_path( + _prik_unit_source "${_prik_unit_source}" "${source_root}" "${output_root}" + ) + endif() + _prik_json_string_list(_prik_unit_flags "${json}" ${_prik_unit_path} ${index} flags) + _prik_json_string_list(_prik_unit_abi_flags "${json}" ${_prik_unit_path} ${index} abi_flags) + _prik_json_string_list(_prik_unit_include_dirs "${json}" ${_prik_unit_path} ${index} include_dirs) + + set(_prik_rebased_include_dirs) + foreach(_prik_include_dir IN LISTS _prik_unit_include_dirs) + _prik_rebase_plan_path( + _prik_rebased_include_dir "${_prik_include_dir}" "${source_root}" "${output_root}" + ) + list(APPEND _prik_rebased_include_dirs "${_prik_rebased_include_dir}") + endforeach() + if(_prik_unit_flags) + set_property( + SOURCE "${_prik_unit_source}" + APPEND PROPERTY COMPILE_OPTIONS ${_prik_unit_flags} + ) + endif() + if(_prik_unit_language STREQUAL "fortran") + set(_prik_cmake_unit_language Fortran) + else() + set(_prik_cmake_unit_language C) + endif() + foreach(_prik_abi_flag IN LISTS _prik_unit_abi_flags) + set(_prik_abi_key "${_prik_cmake_unit_language}:${_prik_abi_flag}") + get_property(_prik_applied_abi_flags TARGET "${target}" PROPERTY _PRIK_APPLIED_ABI_FLAGS) + if(NOT _prik_abi_key IN_LIST _prik_applied_abi_flags) + target_compile_options( + "${target}" PRIVATE "$<$:${_prik_abi_flag}>" + ) + set_property(TARGET "${target}" APPEND PROPERTY _PRIK_APPLIED_ABI_FLAGS "${_prik_abi_key}") + endif() + endforeach() + if(_prik_rebased_include_dirs) + set_property( + SOURCE "${_prik_unit_source}" + APPEND PROPERTY INCLUDE_DIRECTORIES ${_prik_rebased_include_dirs} + ) + endif() +endfunction() + function(_prik_validate_args args) foreach(_prik_arg IN LISTS ${args}) - if(_prik_arg MATCHES "^--(build-manifest|cmake|json|language|makefile|module-name|native-c-sources|native-fortran-sources|no-compile-input-sources|out|out-dir|plan|pyi|sources)(=|$)") + if(_prik_arg MATCHES "^--(analysis-fortran-compiler|build-manifest|cmake|compiler|json|jobs|language|lto|makefile|module-name|native-c-compile-flags|native-c-sources|native-compile-flags|native-fortran-sources|native-library|native-library-dir|native-link-item|native-linker-language|native-objects|no-compile-input-sources|no-standard-logicals|out|out-dir|plan|pyi|sources|wrapper-c-flags|wrapper-compiler-debug|wrapper-fortran-flags)(=|$)") message(FATAL_ERROR "PRIK_ARGS cannot override prik_add_module build ownership: ${_prik_arg}") endif() endforeach() @@ -95,8 +161,8 @@ function(prik_add_module name) message(FATAL_ERROR "PRIK module target already exists: ${name}") endif() - set(_options NO_COMPILE_INPUT_SOURCES) - set(_one_value_arguments CONTRACT) + set(_options NO_COMPILE_INPUT_SOURCES NO_STANDARD_LOGICALS) + set(_one_value_arguments CONTRACT LINKER_LANGUAGE) set(_multi_value_arguments SOURCES FORTRAN_SOURCES @@ -105,6 +171,8 @@ function(prik_add_module name) MODULE_DIRS FORTRAN_FLAGS C_FLAGS + WRAPPER_FORTRAN_FLAGS + WRAPPER_C_FLAGS LINK_LIBRARIES LINK_OPTIONS PRIK_ARGS @@ -121,8 +189,8 @@ function(prik_add_module name) if(PRIK_NO_COMPILE_INPUT_SOURCES AND NOT PRIK_SOURCES) message(FATAL_ERROR "prik_add_module(${name}) uses NO_COMPILE_INPUT_SOURCES only with SOURCES") endif() - if(PRIK_NO_COMPILE_INPUT_SOURCES AND NOT PRIK_FORTRAN_SOURCES AND NOT PRIK_C_SOURCES) - message(FATAL_ERROR "prik_add_module(${name}) requires native implementation sources") + if(PRIK_NO_COMPILE_INPUT_SOURCES AND NOT PRIK_FORTRAN_SOURCES AND NOT PRIK_C_SOURCES AND NOT PRIK_LINK_LIBRARIES) + message(FATAL_ERROR "prik_add_module(${name}) requires native implementation sources or LINK_LIBRARIES") endif() if(NOT PRIK_CONTRACT AND NOT PRIK_SOURCES AND NOT PRIK_FORTRAN_SOURCES AND NOT PRIK_C_SOURCES) message(FATAL_ERROR "prik_add_module(${name}) requires SOURCES, FORTRAN_SOURCES, C_SOURCES, or CONTRACT") @@ -135,6 +203,13 @@ function(prik_add_module name) _prik_make_absolute_paths(_prik_include_dirs ${PRIK_INCLUDE_DIRS}) _prik_make_absolute_paths(_prik_module_dirs ${PRIK_MODULE_DIRS}) + if(PRIK_LINKER_LANGUAGE) + string(TOLOWER "${PRIK_LINKER_LANGUAGE}" _prik_linker_language) + if(NOT _prik_linker_language STREQUAL "c" AND NOT _prik_linker_language STREQUAL "fortran") + message(FATAL_ERROR "prik_add_module(${name}) LINKER_LANGUAGE must be C or Fortran") + endif() + endif() + if(_prik_contract) set(_prik_wrapper_sources) set(_prik_native_fortran_sources ${_prik_fortran_sources}) @@ -143,8 +218,10 @@ function(prik_add_module name) set(_prik_language fortran) elseif(_prik_native_c_sources) set(_prik_language c) + elseif(PRIK_LINK_LIBRARIES AND _prik_linker_language) + set(_prik_language "${_prik_linker_language}") else() - message(FATAL_ERROR "PRIK contract module ${name} requires native FORTRAN_SOURCES or C_SOURCES") + message(FATAL_ERROR "PRIK contract module ${name} requires native sources, or LINK_LIBRARIES with LINKER_LANGUAGE") endif() else() if(_prik_sources) @@ -198,13 +275,27 @@ function(prik_add_module name) if(PRIK_NO_COMPILE_INPUT_SOURCES) list(APPEND _prik_generate_command --no-compile-input-sources) endif() + if((PRIK_NO_COMPILE_INPUT_SOURCES OR _prik_contract) AND NOT _prik_native_fortran_sources AND NOT _prik_native_c_sources) + list(APPEND _prik_generate_command --external-native-implementation) + endif() list(APPEND _prik_generate_command --module-name "${name}") list(APPEND _prik_generate_command --language "${_prik_language}") - # Later PRIK_ARGS may override this when source analysis requires a - # distinct compiler, but CMake's selected toolchain is the default. + # PRIK analyzes and probes with CMake's selected compiler; CMake retains + # ownership of all actual compilation and linking. list(APPEND _prik_generate_command --compiler "${_prik_analysis_compiler}") + if(CMAKE_Fortran_COMPILER) + list(APPEND _prik_generate_command --analysis-fortran-compiler "${CMAKE_Fortran_COMPILER}") + endif() _prik_append_cli_flags(_prik_generate_command --native-compile-flags PRIK_FORTRAN_FLAGS) _prik_append_cli_flags(_prik_generate_command --native-c-compile-flags PRIK_C_FLAGS) + _prik_append_cli_flags(_prik_generate_command --wrapper-fortran-flags PRIK_WRAPPER_FORTRAN_FLAGS) + _prik_append_cli_flags(_prik_generate_command --wrapper-c-flags PRIK_WRAPPER_C_FLAGS) + if(PRIK_NO_STANDARD_LOGICALS) + list(APPEND _prik_generate_command --no-standard-logicals) + endif() + if(_prik_linker_language) + list(APPEND _prik_generate_command --native-linker-language "${_prik_linker_language}") + endif() foreach(_include_dir IN LISTS _prik_include_dirs _prik_module_dirs) list(APPEND _prik_generate_command -I "${_include_dir}") endforeach() @@ -242,7 +333,24 @@ function(prik_add_module name) if(NOT _prik_planned_outputs) message(FATAL_ERROR "PRIK did not return generated outputs for ${name}") endif() - _prik_json_string_list(_prik_semantic_dependencies "${_prik_plan_json}" sources) + _prik_json_string_list(_prik_semantic_dependencies "${_prik_plan_json}" semantic_dependencies) + _prik_json_string_list(_prik_extension_link_flags "${_prik_plan_json}" extension_link_flags) + + string(JSON _prik_linker_language_type TYPE "${_prik_plan_json}" linker_language) + if(_prik_linker_language_type STREQUAL "STRING") + string(JSON _prik_required_linker_language GET "${_prik_plan_json}" linker_language) + elseif(NOT _prik_linker_language_type STREQUAL "NULL") + message(FATAL_ERROR "PRIK returned an invalid linker_language for ${name}") + endif() + + string( + JSON _prik_generated_unit_count + ERROR_VARIABLE _prik_generated_plan_error + LENGTH "${_prik_plan_json}" generated_compilation_units + ) + if(_prik_generated_plan_error) + message(FATAL_ERROR "PRIK did not return generated compilation units: ${_prik_generated_plan_error}") + endif() string( JSON _prik_native_unit_count @@ -317,8 +425,31 @@ function(prik_add_module name) if(_prik_native_target_sources) target_sources("${name}" PRIVATE ${_prik_native_target_sources}) endif() + if(_prik_generated_unit_count GREATER 0) + math(EXPR _prik_last_generated_unit_index "${_prik_generated_unit_count} - 1") + foreach(_prik_generated_unit_index RANGE 0 ${_prik_last_generated_unit_index}) + _prik_apply_compilation_unit( + "${name}" "${_prik_plan_json}" generated ${_prik_generated_unit_index} TRUE + "${_prik_plan_dir}" "${_prik_output_dir}" + ) + endforeach() + endif() + if(_prik_native_unit_count GREATER 0) + math(EXPR _prik_last_native_unit_index "${_prik_native_unit_count} - 1") + foreach(_prik_native_unit_index RANGE 0 ${_prik_last_native_unit_index}) + _prik_apply_compilation_unit( + "${name}" "${_prik_plan_json}" native ${_prik_native_unit_index} FALSE + "${_prik_plan_dir}" "${_prik_output_dir}" + ) + endforeach() + endif() set_target_properties("${name}" PROPERTIES PREFIX "" OUTPUT_NAME "${name}") - if(_prik_language STREQUAL "fortran" OR _prik_native_fortran_sources) + if(_prik_required_linker_language STREQUAL "fortran") + set_property(TARGET "${name}" PROPERTY LINKER_LANGUAGE Fortran) + elseif(_prik_required_linker_language STREQUAL "c") + set_property(TARGET "${name}" PROPERTY LINKER_LANGUAGE C) + endif() + if(_prik_required_linker_language STREQUAL "fortran") set_target_properties("${name}" PROPERTIES Fortran_MODULE_DIRECTORY "${_prik_output_dir}") endif() @@ -330,16 +461,10 @@ function(prik_add_module name) ${_prik_include_dirs} ${_prik_module_dirs} ) - foreach(_flag IN LISTS PRIK_FORTRAN_FLAGS) - target_compile_options("${name}" PRIVATE "$<$:${_flag}>") - endforeach() - foreach(_flag IN LISTS PRIK_C_FLAGS) - target_compile_options("${name}" PRIVATE "$<$:${_flag}>") - endforeach() if(PRIK_LINK_LIBRARIES) target_link_libraries("${name}" PRIVATE ${PRIK_LINK_LIBRARIES}) endif() - if(PRIK_LINK_OPTIONS) - target_link_options("${name}" PRIVATE ${PRIK_LINK_OPTIONS}) + if(_prik_extension_link_flags OR PRIK_LINK_OPTIONS) + target_link_options("${name}" PRIVATE ${_prik_extension_link_flags} ${PRIK_LINK_OPTIONS}) endif() endfunction() diff --git a/docs/developer/packages/pipeline.md b/docs/developer/packages/pipeline.md index 476269185..80b8e4b84 100644 --- a/docs/developer/packages/pipeline.md +++ b/docs/developer/packages/pipeline.md @@ -86,6 +86,7 @@ prik/pipeline/ `build.py` is the orchestration hub. Its public records describe inputs and results without executing a build: `NativeCompilationUnit`, +`GeneratedCompilationUnit`, `NativePrebuiltArtifact`, `NativeLinkItem`, `NativeBuildPlan`, and `WrapperBuildResult`. Its three public entrypoints are source-first builds, contract-first builds, and replay of a saved contract-build manifest. @@ -114,6 +115,13 @@ source collections stay distinct, a source-free `.pyi` build states its native language instead of deriving it from a compiler or ABI decorator, and prebuilt objects, archives, and libraries stay ordered `NativeLinkItem` records. +Build integrations consume the completed result rather than compiler command +logs. Native and generated compilation units retain their own requested flags, +required ABI flags, and include directories; the result also records every +semantic/preprocessing dependency and the final linker language. This is the +lossless boundary used by `UsePRIK.cmake` for source properties, regeneration, +and linker-driver selection. + `WrapperBuildResult` and saved `.pyi` manifests report each generated native group's kind, language, member keys, and source paths, so zero-source, adapter-only, support-only, and mixed output stay factual across direct builds, diff --git a/docs/user/guide/cmake.md b/docs/user/guide/cmake.md index 7e5cf31fc..b8a7c69be 100644 --- a/docs/user/guide/cmake.md +++ b/docs/user/guide/cmake.md @@ -13,7 +13,9 @@ publication: reviewed Use CMake when its toolchain, dependency targets, and build scheduling should own compilation and linking. PRIK still parses the native inputs, completes wrapper policy, and generates the wrapper and any Fortran bridge sources. By -default, PRIK uses CMake's selected C or Fortran compiler for source analysis. +default, PRIK uses CMake's selected C or Fortran compiler for preprocessing, +source analysis, and ABI probes. CMake mode does not accept a separate +`--compiler` override because the analyzed and compiled toolchains must agree. ## Existing CMake project @@ -62,13 +64,41 @@ cmake --build build `prik_add_module()` also accepts `SOURCES` for source-first input, `CONTRACT` with `FORTRAN_SOURCES` or `C_SOURCES` for an authored semantic `.pyi`, -`INCLUDE_DIRS`, `MODULE_DIRS`, language-specific compile flags, -`LINK_LIBRARIES`, `LINK_OPTIONS`, and additional `PRIK_ARGS`. Use -`NO_COMPILE_INPUT_SOURCES` when `SOURCES` supplies only the public interface -and `FORTRAN_SOURCES` or `C_SOURCES` supplies its implementation. The -generated wrapper sources are custom-command outputs. Changing a semantic -source or contract regenerates them before CMake compiles the target; CMake -recompiles contract-first native implementations independently. +`INCLUDE_DIRS`, `MODULE_DIRS`, source-specific compile flags, +`LINK_LIBRARIES`, `LINK_OPTIONS`, and additional generation-only `PRIK_ARGS`. +The flag groups remain separate: + +- `FORTRAN_FLAGS` and `C_FLAGS` apply only to user-owned native sources. +- `WRAPPER_FORTRAN_FLAGS` applies only to generated Fortran bridge sources. +- `WRAPPER_C_FLAGS` applies to generated C sources and the extension link, + matching PRIK's normal build behavior. + +PRIK adds compiler-profile flags required by its ABI plan to the affected +Fortran sources. `NO_STANDARD_LOGICALS` disables PRIK's Intel/NVIDIA logical +interoperability option when compatibility with prebuilt objects requires it. +CMake build type, debug, and interprocedural-optimization settings remain +normal CMake target properties; `PRIK_ARGS` rejects compiler and compilation +options that would bypass those target settings. + +Use `NO_COMPILE_INPUT_SOURCES` when `SOURCES` supplies only the public +interface. Its implementation may come from `FORTRAN_SOURCES`, `C_SOURCES`, a +prebuilt library, or a target in `LINK_LIBRARIES`: + +```cmake +add_library(native_math STATIC implementation.f90) + +prik_add_module( + python_api + SOURCES interface.f90 + NO_COMPILE_INPUT_SOURCES + LINK_LIBRARIES native_math +) +``` + +The generated wrapper sources are custom-command outputs. Changing a semantic +source, contract, included C header, or Fortran `INCLUDE` file regenerates them +before CMake compiles the target. CMake recompiles contract-first native +implementations independently. External dependencies remain CMake dependencies. For example, CMake can find BLAS and pass its target to the PRIK extension: @@ -85,6 +115,10 @@ prik_add_module( The same form accepts normal project targets such as `native_math` and `OpenMP::OpenMP_Fortran`; they remain target-oriented CMake link inputs. +Normal Fortran sources and targets carry their link-language requirements +through CMake. For a raw archive or shared library whose language is otherwise +opaque, add `LINKER_LANGUAGE Fortran`; PRIK records that requirement in its +plan and the extension uses CMake's Fortran linker driver. ## Standalone generated project @@ -107,4 +141,5 @@ python3 -m prik generate --cmake contracts/solver.pyi \ The generated `CMakeLists.txt` loads `UsePRIK.cmake` and calls `prik_add_module()`. `UsePRIK.cmake` integrates PRIK into an existing CMake project; `prik generate --cmake` creates a standalone CMake project that uses -that same helper. +that same helper. `--native-linker-language fortran` emits the explicit raw +library annotation when standalone input requires the Fortran linker. diff --git a/docs/user/reference/cli-commands.md b/docs/user/reference/cli-commands.md index 27705fb6e..eb01a116e 100644 --- a/docs/user/reference/cli-commands.md +++ b/docs/user/reference/cli-commands.md @@ -98,6 +98,7 @@ least one explicit native input: `--native-fortran-sources`, `--native-c-sources | `--native-objects PATH ...` | Links object files, static archives, or shared libraries. | | `--native-library NAME ...` | Links system libraries by name — `--native-library openblas` passes `-lopenblas`. | | `--native-link-item KIND:VALUE ...` | Ordered link items. `KIND` is `object`, `archive`, `shared-library`, `library`, or `arg`. | +| `--native-linker-language {c,fortran}` | Requires the named final linker language when prebuilt inputs do not carry it. | | `--native-library-dir DIR ...` | Library search directories and runtime paths. | | `--lto` | Enables link-time optimization for Fortran and C builds by adding `-flto` to generated and native compilation and to the extension link. | | `--collision-adapter NAME ...` | Calls native symbol `NAME` through a forwarder defined in a separate translation unit, so the binding never declares an identifier its own headers already declare. | @@ -226,12 +227,18 @@ python3 -m prik generate --pyi --language c path/to/api.c --out api.pyi a valid wrapper plan; they skip object compilation and linking, and use `--out-dir`. `--cmake` writes the CMake project; its CMake configuration later runs PRIK's wrapper-generation step, while CMake owns compilation and linking. +In CMake mode, `--compiler` and `--wrapper-compiler-debug` are rejected: +CMake's selected compiler and build configuration own those choices. Native +and generated-wrapper flag options remain distinct in the generated helper +call, `--no-standard-logicals` maps to PRIK's CMake compilation plan, and +`--lto` maps to CMake's interprocedural-optimization target property. With no `--out`, `generate --pyi` prints every generated contract. For Fortran, `--out PATH` names a package directory containing `__init__.pyi` and any module leaves. For C, it names the single output `.pyi` file. Bare `--out` writes beside the inputs. The [source-to-contract layouts](pyi-format.md#source-to-contract-layout) show both forms. -`--compiler` and `-I` affect only preprocessing and datatype measurement. +Outside CMake mode, `--compiler` and `-I` affect preprocessing and datatype +measurement as documented by the selected command. In `.pyi` Makefile mode, PRIK writes `/prik-build.json` first, then generates `/Makefile.prik` from that manifest. diff --git a/docs/user/reference/configuration-files.md b/docs/user/reference/configuration-files.md index 72165903a..a24f7e5da 100644 --- a/docs/user/reference/configuration-files.md +++ b/docs/user/reference/configuration-files.md @@ -92,6 +92,17 @@ A representative manifest has this structure: "requested_name": null }, "generated_wrapper": { + "compilation_units": [ + { + "abi_flags": [], + "flags": [], + "include_dirs": [ + "." + ], + "language": "c", + "source": "module_wrapper.c" + } + ], "native_code_groups": [], "sources": [ "module_wrapper.c" @@ -105,6 +116,7 @@ A representative manifest has this structure: "native_build_plan": { "compilation_units": [ { + "abi_flags": [], "flags": [], "include_dirs": [], "language": "fortran", @@ -121,6 +133,7 @@ A representative manifest has this structure: "path": "module.o" } ], + "linker_language": null, "module_dirs": [ "." ], @@ -134,18 +147,21 @@ A representative manifest has this structure: "shared_library": "module.cpython-.so", "strict_wrapper_names": false }, - "schema_version": 4 + "schema_version": 5 } ``` The values and array contents vary by build. In particular, -`generated_wrapper.native_code_groups` records any generated Fortran adapters -or support sources, while `native_build_plan.link_items` preserves the exact -order of objects, archives, shared libraries, named libraries, and linker -arguments. Paths are stored relative to the manifest directory when possible -and resolved from that directory during replay. - -Replay reads the current schema version, `4`. Regenerate the manifest with the +`generated_wrapper.compilation_units` and +`native_build_plan.compilation_units` keep source-specific flags, required ABI +flags, and include directories. `generated_wrapper.native_code_groups` records +any generated Fortran adapters or support sources, while +`native_build_plan.link_items` preserves the exact order of objects, archives, +shared libraries, named libraries, and linker arguments. Paths are stored +relative to the manifest directory when possible and resolved from that +directory during replay. + +Replay reads the current schema version, `5`. Regenerate the manifest with the current PRIK version when upgrading from an older schema. ## `Makefile.prik` diff --git a/docs/user/reference/python-api.md b/docs/user/reference/python-api.md index 415ed1f76..25b0149eb 100644 --- a/docs/user/reference/python-api.md +++ b/docs/user/reference/python-api.md @@ -97,7 +97,7 @@ Reach past the root facade when you need a single stage rather than a build. | Semantic conversion | `prik.semantics.fortran2ir`, `prik.semantics.pyi2ir` | Fortran conversion helpers, `convert_pyi_to_ir` | | C semantic conversion | `prik.semantics.c2ir` | `CToIRConverter`, `c_file_to_semantic_module`, `c_file_to_semantic_modules` | | `.pyi` loading and stub emission | `prik.pipeline.pyi` | `pyi_*_to_semantic_module`, `emit_module_stubs` | -| Build records and results | `prik.pipeline.build` | `WrapperBuildResult`, `NativeBuildPlan`, `NativeCompilationUnit`, `NativePrebuiltArtifact`, `NativeLinkItem` | +| Build records and results | `prik.pipeline.build` | `WrapperBuildResult`, `NativeBuildPlan`, `NativeCompilationUnit`, `GeneratedCompilationUnit`, `NativePrebuiltArtifact`, `NativeLinkItem` | | IPython/Jupyter integration | `prik.jupyter` | `%load_ext prik.jupyter`, then `%%fortran`, `%%c`, or `%%pyi` | | Target type probing | `prik.preprocessing.probes.fortran_types` | probe source, requirements, expressions, report and error types | | C target type probing | `prik.preprocessing.probes.c_types` | `probe_c_standard_types`, `probe_c_standard_types_cached`, and C probe records/error type | diff --git a/prik/cli.py b/prik/cli.py index 35cf88e20..86cc92a31 100644 --- a/prik/cli.py +++ b/prik/cli.py @@ -936,6 +936,7 @@ def _native_link_options_used(args: argparse.Namespace) -> bool: or getattr(args, "native_libraries", None) or getattr(args, "native_link_items", None) or getattr(args, "native_library_dirs", None) + or getattr(args, "native_linker_language", None) ) @@ -985,7 +986,7 @@ def _validate_pyi_wrapper_options(args: argparse.Namespace, parser: argparse.Arg "--export-symbols selects declarations while reading C source; a semantic .pyi contract " "already states its public functions" ) - if not ( + if not getattr(args, "external_native_implementation", False) and not ( getattr(args, "native_fortran_sources", None) or getattr(args, "native_c_sources", None) or getattr(args, "native_objects", None) @@ -1048,7 +1049,7 @@ def _validate_source_wrapper_options(args: argparse.Namespace, parser: argparse. parser.error(f"A wrapper build found no recognized {label} sources under: {empty_directories[0]}") if not getattr(args, "no_compile_input_sources", False): return - if not ( + if not getattr(args, "external_native_implementation", False) and not ( getattr(args, "native_fortran_sources", None) or getattr(args, "native_c_sources", None) or _prebuilt_native_link_input_used(args) @@ -1078,6 +1079,11 @@ def _validate_wrapper_build_options(args: argparse.Namespace, parser: argparse.A parser.error("generate --sources/--makefile/--cmake uses --out-dir, not --out") if args.command == "generate" and getattr(args, "module_name", None) is not None: _validate_wrapper_out(argparse.Namespace(out=args.module_name), parser) + if getattr(args, "cmake", False): + if getattr(args, "compiler", None): + parser.error("generate --cmake uses CMake's selected compiler; do not pass --compiler") + if getattr(args, "wrapper_compiler_debug", False): + parser.error("generate --cmake does not accept --wrapper-compiler-debug; use CMake build types") if getattr(args, "plan_only", False) and not (args.command == "generate" and args.generate_sources): parser.error("--plan requires generate --sources") if args.command == "build": @@ -1469,7 +1475,11 @@ def record_total_build_time(elapsed: float) -> None: if _wrapper_build_uses_pyi_contract(args): result = build_pyi_extension( args.paths[0], - input_compiler=preprocessing.compiler or "gfortran", + input_compiler=( + getattr(args, "analysis_fortran_compiler", None) + or (preprocessing.compiler if args.language == "fortran" else None) + or "gfortran" + ), input_c_compiler=(preprocessing.compiler or "cc") if args.language == "c" else None, native_language=args.language, native_fortran_sources=getattr(args, "native_fortran_sources", None), @@ -1485,6 +1495,7 @@ def record_total_build_time(elapsed: float) -> None: native_link_items=_cli_native_link_items(getattr(args, "native_link_items", None)), native_library_dirs=getattr(args, "native_library_dirs", None), native_include_dirs=_cli_build_include_dirs(args), + native_linker_language=getattr(args, "native_linker_language", None), output_name=_wrapper_output_name(args), output_dir=getattr(args, "out_dir", None), strict_wrapper_names=getattr(args, "strict_wrapper_names", False), @@ -1494,6 +1505,7 @@ def record_total_build_time(elapsed: float) -> None: makefile=getattr(args, "makefile", False), generate_sources=getattr(args, "generate_sources", False), _plan_only=getattr(args, "plan_only", False), + _external_native_implementation=getattr(args, "external_native_implementation", False), jobs=getattr(args, "jobs", None), standard_logicals=getattr(args, "standard_logicals", True), verbose=1 if getattr(args, "verbose", False) else 0, @@ -1516,7 +1528,8 @@ def record_total_build_time(elapsed: float) -> None: input_c_compiler=getattr(args, "compiler", None), preprocessing=preprocessing, export_symbols=getattr(args, "_resolved_export_symbols", None), - input_compiler="gfortran", + input_compiler=getattr(args, "analysis_fortran_compiler", None) or "gfortran", + compile_input_sources=not getattr(args, "no_compile_input_sources", False), native_c_sources=getattr(args, "native_c_sources", None), native_c_flags=_with_link_time_optimization( _cli_native_c_compile_flags(getattr(args, "native_c_compile_flags", None)), args @@ -1530,6 +1543,7 @@ def record_total_build_time(elapsed: float) -> None: native_link_items=_cli_native_link_items(getattr(args, "native_link_items", None)), native_library_dirs=getattr(args, "native_library_dirs", None), native_include_dirs=_cli_build_include_dirs(args), + native_linker_language=getattr(args, "native_linker_language", None), strict_wrapper_names=getattr(args, "strict_wrapper_names", False), collision_adapters=getattr(args, "collision_adapters", None), collision_adapter_all=getattr(args, "collision_adapter_all", False), @@ -1537,6 +1551,7 @@ def record_total_build_time(elapsed: float) -> None: makefile=getattr(args, "makefile", False), generate_sources=getattr(args, "generate_sources", False), _plan_only=getattr(args, "plan_only", False), + _external_native_implementation=getattr(args, "external_native_implementation", False), jobs=getattr(args, "jobs", None), verbose=1 if getattr(args, "verbose", False) else 0, wrapper_compiler_debug=getattr(args, "wrapper_compiler_debug", False), @@ -1576,9 +1591,11 @@ def record_total_build_time(elapsed: float) -> None: native_link_items=_cli_native_link_items(getattr(args, "native_link_items", None)), native_library_dirs=getattr(args, "native_library_dirs", None), native_include_dirs=_cli_build_include_dirs(args), + native_linker_language=getattr(args, "native_linker_language", None), makefile=getattr(args, "makefile", False), generate_sources=getattr(args, "generate_sources", False), _plan_only=getattr(args, "plan_only", False), + _external_native_implementation=getattr(args, "external_native_implementation", False), jobs=getattr(args, "jobs", None), verbose=1 if getattr(args, "verbose", False) else 0, wrapper_compiler_debug=getattr(args, "wrapper_compiler_debug", False), @@ -2306,6 +2323,11 @@ def _add_native_compilation_options(group: argparse._ArgumentGroup) -> None: def _add_extension_link_options(group: argparse._ArgumentGroup) -> None: + group.add_argument( + "--native-linker-language", + choices=("c", "fortran"), + help="Required final linker language for opaque native inputs", + ) group.add_argument( "--native-objects", dest="native_objects", @@ -2418,6 +2440,9 @@ def _add_diagnostic_controls(group: argparse._ArgumentGroup, *, allow_verbose: b "native_libraries": None, "native_link_items": None, "native_library_dirs": None, + "native_linker_language": None, + "external_native_implementation": False, + "analysis_fortran_compiler": None, "strict_wrapper_names": False, "lto": False, "collision_adapters": None, @@ -2820,6 +2845,17 @@ def _generate_parser(argv: list[str]) -> argparse.ArgumentParser: help="Python module name for generated wrapper sources", ) output_group.add_argument("--plan", dest="plan_only", action="store_true", help=argparse.SUPPRESS) + output_group.add_argument( + "--external-native-implementation", + dest="external_native_implementation", + action="store_true", + help=argparse.SUPPRESS, + ) + output_group.add_argument( + "--analysis-fortran-compiler", + dest="analysis_fortran_compiler", + help=argparse.SUPPRESS, + ) diagnostic_group = parser.add_argument_group("diagnostic options") _add_diagnostic_controls(diagnostic_group) return parser diff --git a/prik/cmake.py b/prik/cmake.py index 25bf74922..4cb718ae0 100644 --- a/prik/cmake.py +++ b/prik/cmake.py @@ -103,8 +103,6 @@ def add_option(option: str, value: object | None = None) -> None: else: result.append(f"{option}={value}") - if args.compiler: - add_option("--compiler", args.compiler) for option, values in ( ("--define", args.defines), ("--undef", args.undefs), @@ -124,20 +122,14 @@ def add_option(option: str, value: object | None = None) -> None: add_option(option, value) if args.include_exposure != "reachable-project": add_option("--include-exposure", args.include_exposure) - for option, values in ( - ("--wrapper-fortran-flags", args.wrapper_fortran_flags), - ("--wrapper-c-flags", args.wrapper_c_flags), - ("--collision-adapter", args.collision_adapters), - ): + for option, values in (("--collision-adapter", args.collision_adapters),): if values: result.extend(f"{option}={value}" for value in values) for option, enabled in ( ("--strict-wrapper-names", args.strict_wrapper_names), ("--assume-intent-in-scalars", args.assume_intent_in_scalars), - ("--wrapper-compiler-debug", args.wrapper_compiler_debug), ("--collision-adapter-all", args.collision_adapter_all), ("--positional-only", args.positional_only), - ("--no-standard-logicals", not args.standard_logicals), ): if enabled: add_option(option) @@ -190,8 +182,11 @@ def _module_inputs(*, paths: Iterable[str | Path], args) -> _CMakeModuleInputs: native_fortran = _absolute_paths(args.native_fortran_sources or ()) native_c = _absolute_paths(args.native_c_sources or ()) - if args.no_compile_input_sources and not native_fortran and not native_c: - raise ValueError("generate --cmake --no-compile-input-sources requires native implementation sources") + has_link_implementation = bool(args.native_objects or args.native_libraries or args.native_link_items) + if args.no_compile_input_sources and not native_fortran and not native_c and not has_link_implementation: + raise ValueError( + "generate --cmake --no-compile-input-sources requires native implementation sources or libraries" + ) return _CMakeModuleInputs( module_name=module_name, semantic_sources=input_paths, @@ -261,17 +256,21 @@ def _append_build_options( native_link_items: Iterable[dict[str, object]], ) -> None: _append_block(lines, "INCLUDE_DIRS", _absolute_paths(args.include_dirs or ()), base=project_dir) - fortran_flags = (*_flag_values(args.native_compile_flags), *_flag_values(args.wrapper_fortran_flags)) - c_flags = (*_flag_values(args.native_c_compile_flags), *_flag_values(args.wrapper_c_flags)) + fortran_flags = _flag_values(args.native_compile_flags) + c_flags = _flag_values(args.native_c_compile_flags) + wrapper_fortran_flags = _flag_values(args.wrapper_fortran_flags) + wrapper_c_flags = _flag_values(args.wrapper_c_flags) libraries, link_options = _link_values(args, base=project_dir, native_link_items=native_link_items) - if args.lto: - fortran_flags = (*fortran_flags, "-flto") - c_flags = (*c_flags, "-flto") - link_options = (*link_options, "-flto") _append_values(lines, "FORTRAN_FLAGS", fortran_flags) _append_values(lines, "C_FLAGS", c_flags) + _append_values(lines, "WRAPPER_FORTRAN_FLAGS", wrapper_fortran_flags) + _append_values(lines, "WRAPPER_C_FLAGS", wrapper_c_flags) _append_values(lines, "LINK_LIBRARIES", libraries) _append_values(lines, "LINK_OPTIONS", link_options) + if args.native_linker_language: + lines.append(f" LINKER_LANGUAGE {args.native_linker_language.capitalize()}") + if not args.standard_logicals: + lines.append(" NO_STANDARD_LOGICALS") _append_values(lines, "PRIK_ARGS", _prik_args(args)) @@ -288,7 +287,11 @@ def write_cmake_project( project_dir.mkdir(parents=True, exist_ok=True) inputs = _module_inputs(paths=paths, args=args) cmake_module_dir() - project_languages = "C Fortran" if language == "fortran" or inputs.native_fortran else "C" + project_languages = ( + "C Fortran" + if language == "fortran" or inputs.native_fortran or args.native_linker_language == "fortran" + else "C" + ) lines = _project_preamble(module_name=inputs.module_name, languages=project_languages) _append_source_declarations( lines, @@ -299,6 +302,8 @@ def write_cmake_project( ) _append_build_options(lines, args=args, project_dir=project_dir, native_link_items=native_link_items) lines.append(")") + if args.lto: + lines.extend(("", f"set_property(TARGET {inputs.module_name} PROPERTY INTERPROCEDURAL_OPTIMIZATION TRUE)")) cmake_lists = project_dir / "CMakeLists.txt" cmake_lists.write_text("\n".join(lines) + "\n", encoding="utf-8") diff --git a/prik/compiler/compilers.py b/prik/compiler/compilers.py index 46c5bc5e4..c65d111eb 100644 --- a/prik/compiler/compilers.py +++ b/prik/compiler/compilers.py @@ -204,6 +204,18 @@ def resolved_executable(self, language: str) -> str: """Return the resolved compiler executable selected for ``language``.""" return self._executable(self._language(language), ()) + def required_abi_flags(self, language: str) -> tuple[str, ...]: + """Return compiler-profile flags required by PRIK's selected ABI. + + These are separate from optimization, debug, position-independent-code, + and caller flags so an external build system can preserve PRIK's ABI + policy while continuing to own its normal toolchain configuration. + """ + configuration = self._language(language) + if language != "fortran" or not self._standard_logicals: + return () + return self._strings(configuration.get("logical_interop_flags", ())) + def compile_object(self, object_file: ObjectFile, *, verbose: bool | int = False) -> tuple[str, ...]: """Compile exactly one source file into its declared object path.""" diff --git a/prik/pipeline/build.py b/prik/pipeline/build.py index b3287f3ab..515bda8e3 100644 --- a/prik/pipeline/build.py +++ b/prik/pipeline/build.py @@ -83,7 +83,7 @@ _DEFAULT_BUILD_DIR_NAME = "__prik__" _BUILD_MANIFEST_NAME = "prik-build.json" -_BUILD_MANIFEST_SCHEMA_VERSION = 4 +_BUILD_MANIFEST_SCHEMA_VERSION = 5 _FORTRAN_SOURCE_SUFFIXES = {".f", ".f03", ".f08", ".f77", ".f90", ".f95", ".for", ".ftn"} _C_SOURCE_SUFFIXES = {".c"} _NATIVE_PATH_LINK_KINDS = frozenset({"object", "archive", "shared_library"}) @@ -200,9 +200,9 @@ class NativeCompilationUnit: Object path produced in the build directory. language Compiler language selected for ``source``. - module_dir, include_dirs, flags + module_dir, include_dirs, flags, abi_flags Module-output location, header/module search paths, and per-source - compiler flags recorded for reproducible builds. + caller flags plus compiler-policy flags required by PRIK's ABI. """ source: Path @@ -211,6 +211,7 @@ class NativeCompilationUnit: module_dir: Path | None = None include_dirs: tuple[Path, ...] = () flags: tuple[str, ...] = () + abi_flags: tuple[str, ...] = () def __post_init__(self) -> None: """Normalize path and flag fields after dataclass construction. @@ -225,6 +226,7 @@ def __post_init__(self) -> None: object.__setattr__(self, "module_dir", Path(self.module_dir)) object.__setattr__(self, "include_dirs", tuple(Path(path) for path in self.include_dirs)) object.__setattr__(self, "flags", tuple(str(flag) for flag in self.flags)) + object.__setattr__(self, "abi_flags", tuple(str(flag) for flag in self.abi_flags)) def to_dict(self) -> dict[str, object]: """Return a JSON-ready representation of this compilation unit. @@ -239,6 +241,38 @@ def to_dict(self) -> dict[str, object]: "module_dir": str(self.module_dir) if self.module_dir is not None else None, "include_dirs": [str(path) for path in self.include_dirs], "flags": list(self.flags), + "abi_flags": list(self.abi_flags), + } + + +@dataclass(frozen=True) +class GeneratedCompilationUnit: + """Describe one generated bridge or binding source compilation. + + External build integrations consume this completed record instead of + reconstructing wrapper-language, flag, include, or ABI requirements. + """ + + source: Path + language: str + include_dirs: tuple[Path, ...] = () + flags: tuple[str, ...] = () + abi_flags: tuple[str, ...] = () + + def __post_init__(self) -> None: + object.__setattr__(self, "source", Path(self.source)) + object.__setattr__(self, "include_dirs", tuple(Path(path) for path in self.include_dirs)) + object.__setattr__(self, "flags", tuple(str(flag) for flag in self.flags)) + object.__setattr__(self, "abi_flags", tuple(str(flag) for flag in self.abi_flags)) + + def to_dict(self) -> dict[str, object]: + """Return this generated compilation unit in JSON-ready form.""" + return { + "source": str(self.source), + "language": self.language, + "include_dirs": [str(path) for path in self.include_dirs], + "flags": list(self.flags), + "abi_flags": list(self.abi_flags), } @@ -353,6 +387,7 @@ class NativeBuildPlan: include_dirs: tuple[Path, ...] = () library_dirs: tuple[Path, ...] = () link_items: tuple[NativeLinkItem, ...] = () + linker_language: str | None = None def __post_init__(self) -> None: """Normalize every collection and filesystem field in this plan. @@ -367,6 +402,8 @@ def __post_init__(self) -> None: object.__setattr__(self, "include_dirs", tuple(Path(path) for path in self.include_dirs)) object.__setattr__(self, "library_dirs", tuple(Path(path) for path in self.library_dirs)) object.__setattr__(self, "link_items", tuple(self.link_items)) + if self.linker_language not in {None, "c", "fortran"}: + raise ValueError("Native build-plan linker language must be 'c', 'fortran', or None") def to_dict(self) -> dict[str, object]: """Return a complete JSON-ready snapshot of the native build plan.""" @@ -378,6 +415,7 @@ def to_dict(self) -> dict[str, object]: "include_dirs": [str(path) for path in self.include_dirs], "library_dirs": [str(path) for path in self.library_dirs], "link_items": [item.to_dict() for item in self.link_items], + "linker_language": self.linker_language, } @@ -406,6 +444,10 @@ class WrapperBuildResult: build_manifest: Path | None = None manifest: dict[str, object] | None = None native_generated_code_groups: tuple[NativeGeneratedCodeGroupPlan, ...] = () + generated_compilation_units: tuple[GeneratedCompilationUnit, ...] = () + semantic_dependencies: tuple[Path, ...] = () + linker_language: str | None = None + extension_link_flags: tuple[str, ...] = () def import_module(self) -> ModuleType: """Import and return this result's built extension module. @@ -468,6 +510,10 @@ def to_dict(self) -> dict[str, object]: "generated_sources": [str(path) for path in self.generated_sources], "generated_files": [str(path) for path in self.generated_files], "native_build_plan": self.native_build_plan.to_dict(), + "generated_compilation_units": [unit.to_dict() for unit in self.generated_compilation_units], + "semantic_dependencies": [str(path) for path in self.semantic_dependencies], + "linker_language": self.linker_language, + "extension_link_flags": list(self.extension_link_flags), "build_manifest": str(self.build_manifest) if self.build_manifest is not None else None, "manifest": self.manifest, "native_generated_code_groups": [ @@ -566,6 +612,34 @@ def _parse_c_wrapper_source(path: Path, preprocessing: PreprocessingConfig): return parsed +def _semantic_dependency_paths( + root: Path, + included_files: Iterable[object], +) -> tuple[Path, ...]: + """Return existing root and transitive preprocessing inputs in stable order.""" + dependencies = [root.resolve(strict=False)] + for item in included_files: + raw_path = item.get("path") if isinstance(item, Mapping) else getattr(item, "path", None) + if not isinstance(raw_path, str | Path) or str(raw_path).startswith("<"): + continue + path = Path(raw_path) + if not path.is_absolute(): + path = root.parent / path + path = path.resolve(strict=False) + if path.is_file(): + dependencies.append(path) + return _unique_paths(dependencies) + + +def _c_wrapper_semantic_dependencies(parsed_sources, source_paths: tuple[Path, ...]) -> tuple[Path, ...]: + """Collect source and included-header dependencies recorded by C preprocessing.""" + dependencies = [] + for parsed, source_path in zip(parsed_sources, source_paths, strict=True): + recipe = parsed.preprocessing_recipe or {} + dependencies.extend(_semantic_dependency_paths(source_path, recipe.get("included_files") or ())) + return _unique_paths(dependencies) + + def _reject_unmodeled_c_declarations(parsed, path: Path) -> None: """Raise when the C parser could not model a declaration written in ``path``. @@ -603,6 +677,17 @@ def _fortran_source_for_pipeline(path: Path, preprocessing: PreprocessingConfig) return path.read_text(encoding="utf-8") +def _fortran_source_and_dependencies( + path: Path, + preprocessing: PreprocessingConfig, +) -> tuple[str, tuple[Path, ...]]: + """Preprocess one wrapper source and retain every interface dependency.""" + if preprocessing.uses_compiler: + result = preprocess_source(path, language="fortran", config=preprocessing) + return result.source, _semantic_dependency_paths(path, result.included_files) + return path.read_text(encoding="utf-8"), _semantic_dependency_paths(path, ()) + + def _compiler_flags(flags: Iterable[str] | None) -> tuple[str, ...]: """Normalize optional caller compiler flags into an immutable tuple. @@ -898,6 +983,24 @@ def _generated_wrapper_object_stages( return bridge_objects, binding_objects +def _generated_compilation_units( + objects: Iterable[ObjectFile], + *, + compiler: Compiler, +) -> tuple[GeneratedCompilationUnit, ...]: + """Expose completed generated-source compile requirements to build tools.""" + return tuple( + GeneratedCompilationUnit( + source=object_file.source, + language=object_file.language, + include_dirs=tuple(object_file.include_dirs), + flags=tuple(object_file.flags), + abi_flags=compiler.required_abi_flags(object_file.language), + ) + for object_file in objects + ) + + def _generated_wrapper_link_language( bridge_objects: tuple[ObjectFile, ...], binding_objects: tuple[ObjectFile, ...], @@ -924,6 +1027,7 @@ def _native_plan_link_languages(plan: NativeBuildPlan) -> tuple[str, ...]: return tuple( dict.fromkeys( ( + *((plan.linker_language,) if plan.linker_language is not None else ()), *(unit.language for unit in plan.compilation_units), *(artifact.language for artifact in plan.prebuilt_artifacts if artifact.language is not None), *(item.language for item in plan.link_items if item.language is not None), @@ -1084,6 +1188,7 @@ def _build_generated_wrapper_extension( output_dir: str | Path, shared_library_output_dir: str | Path | None = None, sources: Iterable[str | Path] = (), + semantic_dependencies: Iterable[str | Path] = (), native_build_plan: NativeBuildPlan | None = None, native_dependencies: Iterable[ObjectFile] = (), native_compile_batches: Iterable[Iterable[ObjectFile]] = (), @@ -1107,6 +1212,34 @@ def _build_generated_wrapper_extension( output_path = Path(output_dir) shared_output_path = Path(shared_library_output_dir) if shared_library_output_dir is not None else output_path resolved_native_build_plan = native_build_plan or NativeBuildPlan() + native_dependencies = tuple(native_dependencies) + compiler = compiler or _new_compiler() + bridge_objects, binding_objects = _generated_wrapper_object_stages( + rendered, + output_path, + wrapper_fortran_flags=_compiler_flags(wrapper_fortran_flags), + wrapper_c_flags=_compiler_flags(wrapper_c_flags), + native_module_dirs=_unique_paths( + ( + *resolved_native_build_plan.module_dirs, + *resolved_native_build_plan.include_dirs, + ) + ), + ) + generated_compilation_units = _generated_compilation_units( + (*bridge_objects, *binding_objects), + compiler=compiler, + ) + linker_language = _generated_wrapper_link_language( + bridge_objects, + binding_objects, + native_objects=native_dependencies, + required_languages=( + *rendered.required_link_languages, + *_native_plan_link_languages(resolved_native_build_plan), + ), + ) + extension_link_flags = _compiler_flags(wrapper_c_flags) if _plan_only: generated_sources = tuple(_generated_source_output_path(output_path, path) for path in rendered.generated_files) native_support_imports = _generated_wrapper_native_support_imports(rendered.native_support_keys) @@ -1127,6 +1260,10 @@ def _build_generated_wrapper_extension( generated_files=(*generated_sources, *native_support_files), native_build_plan=resolved_native_build_plan, native_generated_code_groups=rendered.native_generated_code_groups, + generated_compilation_units=generated_compilation_units, + semantic_dependencies=tuple(Path(path) for path in semantic_dependencies), + linker_language=linker_language, + extension_link_flags=extension_link_flags, ) output_path.mkdir(parents=True, exist_ok=True) @@ -1134,19 +1271,6 @@ def _build_generated_wrapper_extension( _write_generated_wrapper_sources(rendered, output_path, verbose=verbose) # Prepare generated-object inputs and their native support files. - compiler = compiler or _new_compiler() - bridge_objects, binding_objects = _generated_wrapper_object_stages( - rendered, - output_path, - wrapper_fortran_flags=_compiler_flags(wrapper_fortran_flags), - wrapper_c_flags=_compiler_flags(wrapper_c_flags), - native_module_dirs=_unique_paths( - ( - *resolved_native_build_plan.module_dirs, - *resolved_native_build_plan.include_dirs, - ) - ), - ) native_support_imports = _generated_wrapper_native_support_imports(rendered.native_support_keys) install_native_support( native_support_imports, @@ -1169,19 +1293,11 @@ def _build_generated_wrapper_extension( shared_library = compiler.link_extension( module_name=rendered.module_name, output_dir=shared_output_path, - language=_generated_wrapper_link_language( - bridge_objects, - binding_objects, - native_objects=tuple(native_dependencies), - required_languages=( - *rendered.required_link_languages, - *_native_plan_link_languages(resolved_native_build_plan), - ), - ), - objects=(*tuple(native_dependencies), *bridge_objects, *binding_objects), + language=linker_language, + objects=(*native_dependencies, *bridge_objects, *binding_objects), link_args=tuple(native_link_args), library_dirs=resolved_native_build_plan.library_dirs, - flags=_compiler_flags(wrapper_c_flags), + flags=extension_link_flags, verbose=verbose, ) _print_verbose_timing(verbose, time.perf_counter() - linking_started) @@ -1198,13 +1314,17 @@ def _build_generated_wrapper_extension( compiled=True, generated_sources=generated_sources, generated_files=_expected_generated_files( - source_objects=tuple(native_dependencies), + source_objects=native_dependencies, output_dir=output_path, module_name=rendered.module_name, shared_library=shared_library, ), native_build_plan=resolved_native_build_plan, native_generated_code_groups=rendered.native_generated_code_groups, + generated_compilation_units=generated_compilation_units, + semantic_dependencies=tuple(Path(path) for path in semantic_dependencies), + linker_language=linker_language, + extension_link_flags=extension_link_flags, ) @@ -1647,6 +1767,7 @@ class _NativeBuildInputs: link_item_paths: tuple[Path, ...] library_dirs: tuple[Path, ...] explicit_include_dirs: tuple[Path, ...] + linker_language: str | None # Semantic `.pyi` contract loading and export projection @@ -2105,6 +2226,8 @@ def _native_build_plan( explicit_include_dirs: tuple[Path, ...], include_dirs: tuple[Path, ...], module_dir: Path | None, + compiler: Compiler, + linker_language: str | None, ) -> NativeBuildPlan: """Assemble the ordered native compile and link plan for one extension. @@ -2140,6 +2263,7 @@ def _native_build_plan( module_dir=module_dir if source_object.language == "fortran" else None, include_dirs=include_dirs, flags=tuple(source_object.flags), + abi_flags=compiler.required_abi_flags(source_object.language), ) for source_path, source_object in zip(source_paths, source_objects, strict=True) ), @@ -2149,6 +2273,7 @@ def _native_build_plan( include_dirs=include_dirs, library_dirs=library_dirs, link_items=link_items, + linker_language=linker_language, ) @@ -2255,6 +2380,8 @@ def _native_build_inputs( complete_native_link_items: Iterable[NativeLinkItem | dict[str, object]] | None, native_library_dirs: Iterable[str | Path] | None, native_include_dirs: Iterable[str | Path] | None, + native_linker_language: str | None, + allow_empty_native: bool = False, ) -> _NativeBuildInputs: """Validate and normalize all caller-native inputs for a build request. @@ -2288,9 +2415,10 @@ def _native_build_inputs( ) ) explicit_include_dirs = _existing_paths(native_include_dirs, kind="Native include", require_directory=True) + linker_language = _native_link_item_language(native_linker_language) # A wrapper has no native implementation without at least one link input. - if ( + if not allow_empty_native and ( not source_paths and not artifact_paths and not libraries @@ -2313,6 +2441,7 @@ def _native_build_inputs( link_item_paths=link_item_paths, library_dirs=library_dirs, explicit_include_dirs=explicit_include_dirs, + linker_language=linker_language, ) @@ -2336,8 +2465,12 @@ def _native_include_dirs(inputs: _NativeBuildInputs, *, output_path: Path) -> tu def _native_inputs_require_fortran(inputs: _NativeBuildInputs) -> bool: """Return whether explicit native language records require a Fortran driver.""" - return "fortran" in inputs.source_languages or any( - item.language == "fortran" for item in (*inputs.explicit_link_items, *(inputs.complete_link_items or ())) + return ( + inputs.linker_language == "fortran" + or "fortran" in inputs.source_languages + or any( + item.language == "fortran" for item in (*inputs.explicit_link_items, *(inputs.complete_link_items or ())) + ) ) @@ -2408,6 +2541,7 @@ def _prepare_native_build_plan( inputs: _NativeBuildInputs, *, output_path: Path, + compiler: Compiler, ) -> tuple[tuple[ObjectFile, ...], NativeBuildPlan]: """Create and validate compiler objects and link inputs for one build.""" include_dirs = _native_include_dirs(inputs, output_path=output_path) @@ -2427,6 +2561,8 @@ def _prepare_native_build_plan( explicit_include_dirs=inputs.explicit_include_dirs, include_dirs=include_dirs, module_dir=output_path if any(source.language == "fortran" for source in source_objects) else None, + compiler=compiler, + linker_language=inputs.linker_language, ) _validate_native_link_paths(plan) return source_objects, plan @@ -2499,6 +2635,7 @@ def _manifest_native_plan(plan: NativeBuildPlan, *, base: Path) -> dict[str, obj "module_dir": _manifest_path(unit.module_dir, base=base) if unit.module_dir is not None else None, "include_dirs": [_manifest_path(path, base=base) for path in unit.include_dirs], "flags": list(unit.flags), + "abi_flags": list(unit.abi_flags), } for unit in plan.compilation_units ], @@ -2517,6 +2654,7 @@ def _manifest_native_plan(plan: NativeBuildPlan, *, base: Path) -> dict[str, obj "include_dirs": [_manifest_path(path, base=base) for path in plan.include_dirs], "library_dirs": [_manifest_path(path, base=base) for path in plan.library_dirs], "link_items": [_manifest_link_item(item, base=base) for item in plan.link_items], + "linker_language": plan.linker_language, } @@ -2543,6 +2681,16 @@ def _manifest_generated_wrapper(result: WrapperBuildResult, *, base: Path) -> di """Serialize physical sources and independently planned native membership.""" return { "sources": [_manifest_path(path, base=base) for path in result.generated_sources], + "compilation_units": [ + { + "source": _manifest_path(unit.source, base=base), + "language": unit.language, + "include_dirs": [_manifest_path(path, base=base) for path in unit.include_dirs], + "flags": list(unit.flags), + "abi_flags": list(unit.abi_flags), + } + for unit in result.generated_compilation_units + ], "native_code_groups": [ { "kind": group.kind.value, @@ -3169,12 +3317,15 @@ def _fortran_wrapper_module( fortran_type_probe_cache_dir: str | Path | None, refresh_fortran_type_probe: bool, assume_intent_in_scalars: bool = False, -) -> tuple[object, SemanticModule, tuple[SemanticModule, ...]]: +) -> tuple[object, SemanticModule, tuple[SemanticModule, ...], tuple[Path, ...]]: """Parse Fortran sources, resolve type facts, and form one wrapper module.""" # Preprocess and parse the complete source project. - preprocessed_sources = { - str(source_path): _fortran_source_for_pipeline(source_path, preprocessing) for source_path in source_paths - } + preprocessed_sources = {} + semantic_dependencies = [] + for source_path in source_paths: + source, dependencies = _fortran_source_and_dependencies(source_path, preprocessing) + preprocessed_sources[str(source_path)] = source + semantic_dependencies.extend(dependencies) parsed = parse_fortran_project(preprocessed_sources) # Measure compiler-dependent values before building semantic IR. @@ -3205,7 +3356,12 @@ def _fortran_wrapper_module( ) _apply_source_python_exports(modules) module_name = _validated_wrapper_module_name(output_name, source_paths[0].stem) - return parsed, _merge_wrapper_modules(modules, name=module_name), tuple(modules) + return ( + parsed, + _merge_wrapper_modules(modules, name=module_name), + tuple(modules), + _unique_paths(semantic_dependencies), + ) def _complete_pyi_fortran_boolean_types( @@ -3314,6 +3470,7 @@ def build_fortran_extension( native_link_items: Iterable[NativeLinkItem | dict[str, object]] | None = None, native_library_dirs: Iterable[str | Path] | None = None, native_include_dirs: Iterable[str | Path] | None = None, + native_linker_language: str | None = None, makefile: bool = False, generate_sources: bool = False, jobs: int | None = None, @@ -3324,6 +3481,7 @@ def build_fortran_extension( standard_logicals: bool = True, _on_total_build_time: Callable[[float], None] | None = None, _plan_only: bool = False, + _external_native_implementation: bool = False, ) -> WrapperBuildResult: """Build a Python extension from one or more Fortran source files. @@ -3376,10 +3534,11 @@ def build_fortran_extension( Additional explicit Fortran or C implementation sources and their language-specific compiler flags. native_objects, native_libraries, native_link_items, - native_library_dirs, native_include_dirs + native_library_dirs, native_include_dirs, native_linker_language Existing artifacts, ``-l`` names, ordered linker records, and search - paths for the native implementation. Use ``native_link_items`` when - linker order is significant. + paths for the native implementation. Use ``native_link_items`` when + linker order is significant, and state ``"fortran"`` or ``"c"`` when + opaque prebuilt inputs require a particular final linker language. makefile, generate_sources Choose a non-executing output mode. ``makefile=True`` writes a replayable ``Makefile.prik``; ``generate_sources=True`` writes wrapper @@ -3419,6 +3578,8 @@ def build_fortran_extension( jobs=jobs, verbose=verbose, ) + if _external_native_implementation and not (generate_sources or _plan_only): + raise ValueError("An external native implementation is valid only for source generation or planning") build_started = time.perf_counter() @@ -3442,11 +3603,13 @@ def build_fortran_extension( complete_native_link_items=None, native_library_dirs=native_library_dirs, native_include_dirs=native_include_dirs, + native_linker_language=native_linker_language, + allow_empty_native=_external_native_implementation, ) type_probe_preprocessing = _type_probe_preprocessing(preprocessing, native_inputs.fortran_source_flags) # 2. Parse source, resolve target facts, and assemble semantic IR. - parsed, module, source_modules = _fortran_wrapper_module( + parsed, module, source_modules, semantic_dependencies = _fortran_wrapper_module( source_paths, preprocessing=preprocessing, type_probe_preprocessing=type_probe_preprocessing, @@ -3479,7 +3642,11 @@ def build_fortran_extension( standard_logicals=standard_logicals, input_compiler=preprocessing.compiler if preprocessing.uses_compiler else None, ) - native_source_objects, native_build_plan = _prepare_native_build_plan(native_inputs, output_path=output_path) + native_source_objects, native_build_plan = _prepare_native_build_plan( + native_inputs, + output_path=output_path, + compiler=compiler, + ) native_compile_batches = _project_compile_batches(parsed, native_source_objects) # 5. Build the extension, or retain the generated source/Makefile plan. @@ -3488,6 +3655,7 @@ def build_fortran_extension( output_dir=output_path, shared_library_output_dir=shared_library_output_path, sources=source_paths, + semantic_dependencies=semantic_dependencies, native_build_plan=native_build_plan, native_dependencies=native_source_objects, native_compile_batches=native_compile_batches, @@ -3526,6 +3694,7 @@ def build_c_extension( c_type_report=None, c_type_probe_runner: list[str] | None = None, export_symbols: Iterable[str] | None = None, + compile_input_sources: bool = True, native_c_sources: Iterable[str | Path] | None = None, native_c_flags: Iterable[str] | None = None, native_fortran_sources: Iterable[str | Path] | None = None, @@ -3536,6 +3705,7 @@ def build_c_extension( native_link_items: Iterable[NativeLinkItem | dict[str, object]] | None = None, native_library_dirs: Iterable[str | Path] | None = None, native_include_dirs: Iterable[str | Path] | None = None, + native_linker_language: str | None = None, strict_wrapper_names: bool = False, collision_adapters: Iterable[str] | None = None, collision_adapter_all: bool = False, @@ -3550,6 +3720,7 @@ def build_c_extension( standard_logicals: bool = True, _on_total_build_time: Callable[[float], None] | None = None, _plan_only: bool = False, + _external_native_implementation: bool = False, ) -> WrapperBuildResult: """Build a direct-only C extension from explicit C implementation sources. @@ -3563,7 +3734,8 @@ def build_c_extension( identifier collision may use a separate C forwarder translation unit. ``export_symbols`` restricts semantic conversion to those exact reachable C functions and can explicitly select declarations from included headers. - ``native_c_sources`` adds separately compiled C inputs, while explicit + ``compile_input_sources`` controls whether the parsed C sources are also + compiled. ``native_c_sources`` adds separately compiled C inputs, while explicit Fortran inputs are supported only as ordinary link dependencies. ``standard_logicals`` controls whether those Fortran inputs are compiled with the option that gives a ``logical`` the representation C expects @@ -3582,6 +3754,8 @@ def build_c_extension( jobs=jobs, verbose=verbose, ) + if _external_native_implementation and not (generate_sources or _plan_only): + raise ValueError("An external native implementation is valid only for source generation or planning") build_started = time.perf_counter() selected_exports = None if export_symbols is None else tuple(export_symbols) source_paths = _c_source_paths(sources) @@ -3590,7 +3764,7 @@ def build_c_extension( native_inputs = _native_build_inputs( native_fortran_sources=native_fortran_sources, native_fortran_flags=native_fortran_flags, - native_c_sources=(*source_paths, *supplemental_c_paths), + native_c_sources=(*(source_paths if compile_input_sources else ()), *supplemental_c_paths), native_c_flags=native_c_flags, native_objects=native_objects, native_libraries=native_libraries, @@ -3598,6 +3772,8 @@ def build_c_extension( complete_native_link_items=None, native_library_dirs=native_library_dirs, native_include_dirs=native_include_dirs, + native_linker_language=native_linker_language, + allow_empty_native=_external_native_implementation, ) requires_fortran = _native_inputs_require_fortran(native_inputs) compiler, preprocessing = _c_build_compiler_and_preprocessing( @@ -3610,6 +3786,7 @@ def build_c_extension( standard_logicals=standard_logicals, ) parsed_sources = tuple(_parse_c_wrapper_source(path, preprocessing) for path in source_paths) + semantic_dependencies = _c_wrapper_semantic_dependencies(parsed_sources, source_paths) # Fail forms that are intrinsically outside the primitive lane before the # ABI probe, generated files, or native build commands. A supported source # may still need the probe to resolve target-sized arithmetic facts. @@ -3660,7 +3837,11 @@ def build_c_extension( output_path, verbose=verbose, ) - native_source_objects, native_build_plan = _prepare_native_build_plan(native_inputs, output_path=output_path) + native_source_objects, native_build_plan = _prepare_native_build_plan( + native_inputs, + output_path=output_path, + compiler=compiler, + ) wrapper_fortran_flags = _compiler_flags(wrapper_fortran_flags) wrapper_c_flags = _compiler_flags(wrapper_c_flags) result = _build_generated_wrapper_extension( @@ -3668,6 +3849,7 @@ def build_c_extension( output_dir=output_path, shared_library_output_dir=shared_library_output_path, sources=source_paths, + semantic_dependencies=semantic_dependencies, native_build_plan=native_build_plan, native_dependencies=native_source_objects, native_compile_batches=_serial_compile_batches(native_source_objects), @@ -3711,6 +3893,7 @@ def build_pyi_extension( native_link_items: Iterable[NativeLinkItem | dict[str, object]] | None = None, native_library_dirs: Iterable[str | Path] | None = None, native_include_dirs: Iterable[str | Path] | None = None, + native_linker_language: str | None = None, output_name: str | None = None, output_dir: str | Path | None = None, strict_wrapper_names: bool = False, @@ -3728,6 +3911,7 @@ def build_pyi_extension( standard_logicals: bool = True, _on_total_build_time: Callable[[float], None] | None = None, _plan_only: bool = False, + _external_native_implementation: bool = False, ) -> WrapperBuildResult: """Build a Python extension from an editable semantic ``.pyi`` contract. @@ -3760,10 +3944,11 @@ def build_pyi_extension( Existing implementation source paths to compile and their language-specific compiler flags. native_objects, native_libraries, native_link_items, - native_library_dirs, native_include_dirs + native_library_dirs, native_include_dirs, native_linker_language Existing artifacts, ``-l`` names, ordered linker records, and search paths. Use ``native_link_items`` to append ordered inputs, or ``complete_native_link_items`` to supply the full ordered link plan. + State a linker language when opaque prebuilt inputs require one. output_name, output_dir Optional Python extension name and build directory. The default name comes from the contract file or package entry. @@ -3804,6 +3989,8 @@ def build_pyi_extension( jobs=jobs, verbose=verbose, ) + if _external_native_implementation and not (generate_sources or _plan_only): + raise ValueError("An external native implementation is valid only for source generation or planning") build_started = time.perf_counter() @@ -3825,6 +4012,8 @@ def build_pyi_extension( complete_native_link_items=complete_native_link_items, native_library_dirs=native_library_dirs, native_include_dirs=native_include_dirs, + native_linker_language=native_linker_language, + allow_empty_native=_external_native_implementation, ) output_path, shared_library_output_path = _wrapper_output_paths(output_dir) @@ -3863,8 +4052,7 @@ def build_pyi_extension( if not _plan_only: output_path.mkdir(parents=True, exist_ok=True) - # 3. Prepare native compilation and link inputs before selecting the compiler. - native_source_objects, native_build_plan = _prepare_native_build_plan(native_inputs, output_path=output_path) + # 3. Select the compiler profile and complete the native compilation plan. compiler = _new_compiler( execute_commands=not generation_only, debug=wrapper_compiler_debug, @@ -3873,6 +4061,11 @@ def build_pyi_extension( input_c_compiler=selected_input_c_compiler, requires_fortran=_native_inputs_require_fortran(native_inputs) or native_language == "fortran", ) + native_source_objects, native_build_plan = _prepare_native_build_plan( + native_inputs, + output_path=output_path, + compiler=compiler, + ) resolved_input_c_compiler = compiler.resolved_executable("c") native_array_build_requirements = native_array_handle_build_requirements(module) @@ -3882,6 +4075,7 @@ def build_pyi_extension( output_dir=output_path, shared_library_output_dir=shared_library_output_path, sources=bundle.paths, + semantic_dependencies=bundle.paths, native_build_plan=native_build_plan, native_dependencies=native_source_objects, native_compile_batches=_serial_compile_batches(native_source_objects), @@ -4010,6 +4204,7 @@ def build_pyi_extension_from_manifest( collision_adapters = _manifest_string_list(extension_section, "collision_adapters") collision_adapter_all = _manifest_bool(extension_section, "collision_adapter_all") positional_only = _manifest_bool(extension_section, "positional_only") + native_linker_language = _native_link_item_language(native_section.get("linker_language")) # 2. Restore native include paths and compiler selection from the manifest. manifest_module_dirs = _manifest_path_list(native_section, "module_dirs", base=base) @@ -4052,6 +4247,7 @@ def build_pyi_extension_from_manifest( native_c_flags=_manifest_string_list(compiler_section, "c_flags"), native_include_dirs=native_include_dirs, native_library_dirs=_manifest_path_list(native_section, "library_dirs", base=base), + native_linker_language=native_linker_language, output_name=requested_name, output_dir=output_path, strict_wrapper_names=strict_wrapper_names, diff --git a/tests/fortran/infrastructure/building/compiling/test_logical_interop_flags.py b/tests/fortran/infrastructure/building/compiling/test_logical_interop_flags.py index e7c14e310..49df78047 100644 --- a/tests/fortran/infrastructure/building/compiling/test_logical_interop_flags.py +++ b/tests/fortran/infrastructure/building/compiling/test_logical_interop_flags.py @@ -43,3 +43,25 @@ def test_compilers_that_already_interoperate_add_no_logical_option(vendor: str, command = _fortran_compile_command(vendor, standard_logicals=True, tmp_path=tmp_path) assert "-standard-semantics" not in command assert "-Munixlogical" not in command + + +@pytest.mark.parametrize( + ("vendor", "expected"), + [ + ("intel", ("-standard-semantics",)), + ("PGI", ("-Munixlogical",)), + ("nvidia", ("-Munixlogical",)), + ("GNU", ()), + ], +) +def test_compiler_exposes_required_logical_abi_flags_for_external_builds(vendor: str, expected: tuple[str, ...]): + compiler = Compiler(vendor, execute_commands=False) + + assert compiler.required_abi_flags("fortran") == expected + assert compiler.required_abi_flags("c") == () + + +def test_external_build_can_disable_required_logical_abi_flags(): + compiler = Compiler("intel", execute_commands=False, standard_logicals=False) + + assert compiler.required_abi_flags("fortran") == () diff --git a/tests/fortran/infrastructure/building/end_to_end/test_cmake_builds.py b/tests/fortran/infrastructure/building/end_to_end/test_cmake_builds.py index 509684c21..670a60b94 100644 --- a/tests/fortran/infrastructure/building/end_to_end/test_cmake_builds.py +++ b/tests/fortran/infrastructure/building/end_to_end/test_cmake_builds.py @@ -3,6 +3,7 @@ from __future__ import annotations import importlib +import importlib.metadata import json import os from pathlib import Path @@ -10,8 +11,10 @@ import subprocess import sys import tempfile +import venv import numpy as np +from packaging.version import Version import pytest @@ -31,6 +34,7 @@ BRIDGE_NATIVE = ( REPOSITORY_ROOT / "tests" / "fortran" / "functions" / "end_to_end" / "fixtures" / "native" / "free_external.f90" ) +SETUPTOOLS_SUPPORTS_PROJECT_DATA_FILES = Version(importlib.metadata.version("setuptools")) >= Version("77.0.3") def _environment() -> dict[str, str]: @@ -40,8 +44,13 @@ def _environment() -> dict[str, str]: return environment -def _run(command: list[str], *, cwd: Path | None = None) -> subprocess.CompletedProcess[str]: - result = subprocess.run(command, cwd=cwd, env=_environment(), capture_output=True, text=True) +def _run( + command: list[str], + *, + cwd: Path | None = None, + environment: dict[str, str] | None = None, +) -> subprocess.CompletedProcess[str]: + result = subprocess.run(command, cwd=cwd, env=environment or _environment(), capture_output=True, text=True) if result.returncode: raise AssertionError( f"Command failed ({result.returncode}): {' '.join(command)}\n" @@ -57,6 +66,8 @@ def _configure_and_build( language: str, use_ninja: bool = True, build_project: bool = True, + environment: dict[str, str] | None = None, + python_executable: Path | None = None, ) -> None: command = ["cmake", "-S", str(project), "-B", str(build)] if use_ninja and shutil.which("ninja"): @@ -64,9 +75,11 @@ def _configure_and_build( command.append(f"-DCMAKE_C_COMPILER={shutil.which('gcc')}") if language == "fortran": command.append(f"-DCMAKE_Fortran_COMPILER={shutil.which('gfortran')}") - _run(command) + if python_executable is not None: + command.append(f"-DPython_EXECUTABLE={python_executable}") + _run(command, environment=environment) if build_project: - _run(["cmake", "--build", str(build), "-j2"]) + _run(["cmake", "--build", str(build), "-j2"], environment=environment) def _import_extension(module_name: str, build: Path): @@ -152,6 +165,102 @@ def test_use_prik_cmake_builds_source_first_fortran_module(tmp_path: Path): assert module.square(np.float64(3.0)) == np.float64(9.0) +@pytest.mark.fortran_end_to_end +@pytest.mark.skipif( + shutil.which("cmake") is None or shutil.which("gfortran") is None or shutil.which("gcc") is None, + reason="CMake, gfortran, and gcc are required", +) +def test_use_prik_cmake_preserves_per_source_compile_flags(tmp_path: Path): + project = tmp_path / "compile flag scopes" + project.mkdir() + (project / "native.f90").write_text( + "real(8) function native_value(x) result(y)\n real(8), intent(in) :: x\n y = x\nend function native_value\n", + encoding="utf-8", + ) + (project / "support.c").write_text("int prik_native_support(void) { return 0; }\n", encoding="utf-8") + _write_project( + project, + """set(CMAKE_EXPORT_COMPILE_COMMANDS ON) +prik_add_module( + compile_flag_scopes + SOURCES native.f90 + C_SOURCES support.c + FORTRAN_FLAGS -DPRIK_NATIVE_FORTRAN + C_FLAGS -DPRIK_NATIVE_C + WRAPPER_FORTRAN_FLAGS -DPRIK_WRAPPER_FORTRAN + WRAPPER_C_FLAGS -DPRIK_WRAPPER_C +) +""", + ) + build = project / "build" + _configure_and_build(project, build, language="fortran") + commands = json.loads((build / "compile_commands.json").read_text(encoding="utf-8")) + by_name = {Path(record["file"]).name: record["command"] for record in commands} + + assert "-DPRIK_NATIVE_FORTRAN" in by_name["native.f90"] + assert "-DPRIK_WRAPPER_FORTRAN" not in by_name["native.f90"] + assert "-DPRIK_NATIVE_C" in by_name["support.c"] + assert "-DPRIK_WRAPPER_C" not in by_name["support.c"] + bridge_command = next(command for name, command in by_name.items() if name.endswith("_wrapper.f90")) + binding_command = next(command for name, command in by_name.items() if name.endswith("_wrapper.c")) + assert "-DPRIK_WRAPPER_FORTRAN" in bridge_command + assert "-DPRIK_NATIVE_FORTRAN" not in bridge_command + assert "-DPRIK_WRAPPER_C" in binding_command + assert "-DPRIK_NATIVE_C" not in binding_command + + +@pytest.mark.fortran_end_to_end +@pytest.mark.skipif( + shutil.which("cmake") is None or shutil.which("gfortran") is None or shutil.which("gcc") is None, + reason="CMake, gfortran, and gcc are required", +) +def test_use_prik_cmake_maps_required_logical_abi_flags(tmp_path: Path): + project = tmp_path / "logical abi flags" + toolchain = project / "toolchain" + toolchain.mkdir(parents=True) + for name, compiler in (("ifort", shutil.which("gfortran")), ("icx", shutil.which("gcc"))): + executable = toolchain / name + executable.write_text(f'#!/bin/sh\nexec "{compiler}" "$@"\n', encoding="utf-8") + executable.chmod(0o755) + source_text = ( + "logical function logical_identity(value) result(output)\n" + " logical, intent(in) :: value\n" + " output = value\n" + "end function logical_identity\n" + ) + (project / "logical_default.f90").write_text(source_text, encoding="utf-8") + (project / "logical_disabled.f90").write_text(source_text, encoding="utf-8") + _write_project( + project, + """set(CMAKE_EXPORT_COMPILE_COMMANDS ON) +prik_add_module(abi_default SOURCES logical_default.f90) +prik_add_module(abi_disabled SOURCES logical_disabled.f90 NO_STANDARD_LOGICALS) +""", + ) + build = project / "build" + _run( + [ + "cmake", + "-S", + str(project), + "-B", + str(build), + "-G", + "Ninja" if shutil.which("ninja") else "Unix Makefiles", + f"-DCMAKE_C_COMPILER={shutil.which('gcc')}", + f"-DCMAKE_Fortran_COMPILER={toolchain / 'ifort'}", + ] + ) + commands = json.loads((build / "compile_commands.json").read_text(encoding="utf-8")) + default_commands = [record["command"] for record in commands if "abi_default" in record["command"]] + disabled_commands = [record["command"] for record in commands if "abi_disabled" in record["command"]] + + assert default_commands + assert any("-standard-semantics" in command for command in default_commands) + assert disabled_commands + assert all("-standard-semantics" not in command for command in disabled_commands) + + @pytest.mark.fortran_end_to_end @pytest.mark.skipif( shutil.which("cmake") is None or shutil.which("gfortran") is None or shutil.which("gcc") is None, @@ -261,6 +370,63 @@ def test_generate_cmake_preserves_ordered_native_link_items(tmp_path: Path): assert positions == tuple(sorted(positions)) +@pytest.mark.fortran_end_to_end +def test_generate_cmake_keeps_compile_option_ownership_explicit(tmp_path: Path): + source = tmp_path / "interface.f90" + source.write_text("subroutine interface()\nend subroutine interface\n", encoding="utf-8") + archive = tmp_path / "libimplementation.a" + archive.touch() + project = tmp_path / "explicit options" + _run( + [ + sys.executable, + "-m", + "prik", + "generate", + "--cmake", + str(source), + "--no-compile-input-sources", + "--native-objects", + str(archive), + "--native-linker-language", + "fortran", + "--native-compile-flags=-DPRIK_NATIVE", + "--wrapper-fortran-flags=-DPRIK_WRAPPER_FORTRAN", + "--wrapper-c-flags=-DPRIK_WRAPPER_C", + "--no-standard-logicals", + "--lto", + "--out-dir", + str(project), + ] + ) + cmake_lists = (project / "CMakeLists.txt").read_text(encoding="utf-8") + + assert 'FORTRAN_FLAGS\n "-DPRIK_NATIVE"' in cmake_lists + assert 'WRAPPER_FORTRAN_FLAGS\n "-DPRIK_WRAPPER_FORTRAN"' in cmake_lists + assert 'WRAPPER_C_FLAGS\n "-DPRIK_WRAPPER_C"' in cmake_lists + assert "LINKER_LANGUAGE Fortran" in cmake_lists + assert "NO_STANDARD_LOGICALS" in cmake_lists + assert "INTERPROCEDURAL_OPTIMIZATION TRUE" in cmake_lists + assert "--compiler" not in cmake_lists + + +@pytest.mark.parametrize("option", ["--compiler=gfortran", "--wrapper-compiler-debug"]) +@pytest.mark.fortran_end_to_end +def test_generate_cmake_rejects_ambiguous_direct_compiler_options(tmp_path: Path, option: str): + source = tmp_path / "source.f90" + source.write_text("subroutine source()\nend subroutine source\n", encoding="utf-8") + + result = subprocess.run( + [sys.executable, "-m", "prik", "generate", "--cmake", str(source), option], + env=_environment(), + capture_output=True, + text=True, + ) + + assert result.returncode == 2 + assert "generate --cmake" in result.stderr + + @pytest.mark.fortran_end_to_end @pytest.mark.skipif( shutil.which("cmake") is None or shutil.which("gfortran") is None or shutil.which("gcc") is None, @@ -363,6 +529,72 @@ def test_use_prik_cmake_contract_dependency_regenerates_wrapper(tmp_path: Path): assert "Generate PRIK wrapper sources for contract_example" in contract_output +@pytest.mark.fortran_end_to_end +@pytest.mark.skipif(shutil.which("cmake") is None or shutil.which("gcc") is None, reason="CMake and gcc are required") +def test_use_prik_cmake_regenerates_after_nested_c_header_changes(tmp_path: Path): + project = tmp_path / "nested c dependency" + include_dir = project / "include" + include_dir.mkdir(parents=True) + inner_header = include_dir / "inner.h" + inner_header.write_text("double c_square(double value);\n", encoding="utf-8") + (include_dir / "api.h").write_text('#include "inner.h"\n', encoding="utf-8") + (project / "module.c").write_text( + '#include "api.h"\ndouble c_square(double value) { return value * value; }\n', + encoding="utf-8", + ) + _write_project( + project, + f"""prik_add_module( + c_header_dependency + C_SOURCES module.c + INCLUDE_DIRS "{include_dir.as_posix()}" +) +""", + languages="C", + ) + build = project / "build" + _configure_and_build(project, build, language="c") + + inner_header.write_text("double c_square(double input);\n", encoding="utf-8") + rebuilt = _run(["cmake", "--build", str(build), "-j2"]) + + assert "Generate PRIK wrapper sources for c_header_dependency" in rebuilt.stdout + rebuilt.stderr + + +@pytest.mark.fortran_end_to_end +@pytest.mark.skipif( + shutil.which("cmake") is None or shutil.which("gfortran") is None or shutil.which("gcc") is None, + reason="CMake, gfortran, and gcc are required", +) +def test_use_prik_cmake_regenerates_after_fortran_include_changes(tmp_path: Path): + project = tmp_path / "fortran include dependency" + project.mkdir() + include = project / "declarations.inc" + include.write_text("implicit none\n real(8), intent(in) :: x\n", encoding="utf-8") + (project / "included.f90").write_text( + "real(8) function included_square(x) result(y)\n" + " include 'declarations.inc'\n" + " y = x * x\n" + "end function included_square\n", + encoding="utf-8", + ) + _write_project( + project, + """prik_add_module( + fortran_include_dependency + FORTRAN_SOURCES included.f90 +) +""", + ) + build = project / "build" + _configure_and_build(project, build, language="fortran") + + include.write_text("implicit none\n double precision, intent(in) :: x\n", encoding="utf-8") + rebuilt = _run(["cmake", "--build", str(build), "-j2"]) + + assert "Generate PRIK wrapper sources for fortran_include_dependency" in rebuilt.stdout + rebuilt.stderr + + @pytest.mark.fortran_end_to_end @pytest.mark.skipif( shutil.which("cmake") is None or shutil.which("gfortran") is None or shutil.which("gcc") is None, @@ -564,6 +796,87 @@ def test_use_prik_cmake_links_a_normal_fortran_library_target(tmp_path: Path): assert module.call_native(np.float64(2.0), np.float64(3.0)) == np.float64(5.0) +@pytest.mark.fortran_end_to_end +@pytest.mark.skipif( + shutil.which("cmake") is None or shutil.which("gfortran") is None or shutil.which("gcc") is None, + reason="CMake, gfortran, and gcc are required", +) +def test_use_prik_cmake_uses_target_as_the_only_native_implementation(tmp_path: Path): + project = tmp_path / "target only implementation" + project.mkdir() + interface = ( + "real(8) function target_square(value) result(result)\n" + " real(8), intent(in) :: value\n" + " result = value * value\n" + "end function target_square\n" + ) + (project / "interface.f90").write_text(interface, encoding="utf-8") + (project / "implementation.f90").write_text(interface, encoding="utf-8") + _write_project( + project, + """add_library(native_math STATIC implementation.f90) +prik_add_module( + target_only + SOURCES interface.f90 + NO_COMPILE_INPUT_SOURCES + LINK_LIBRARIES native_math +) +""", + ) + build = project / "build" + _configure_and_build(project, build, language="fortran") + + module = _import_extension("target_only", build) + assert module.target_square(np.float64(4.0)) == np.float64(16.0) + + +@pytest.mark.fortran_end_to_end +@pytest.mark.skipif( + shutil.which("cmake") is None + or shutil.which("gfortran") is None + or shutil.which("gcc") is None + or shutil.which("ar") is None, + reason="CMake, gfortran, gcc, and ar are required", +) +def test_use_prik_cmake_selects_fortran_linker_for_raw_archive(tmp_path: Path): + project = tmp_path / "raw fortran archive" + project.mkdir() + source_text = ( + "integer(c_int) function raw_add_two(value) bind(C, name='raw_add_two_symbol') result(output)\n" + " use iso_c_binding, only: c_int\n" + " integer(c_int), value, intent(in) :: value\n" + " character(len=16) :: buffer\n" + " write(buffer, '(I0)') value\n" + " read(buffer, *) output\n" + " output = output + 2_c_int\n" + "end function raw_add_two\n" + ) + interface = project / "interface.f90" + implementation = project / "implementation.f90" + interface.write_text(source_text, encoding="utf-8") + implementation.write_text(source_text, encoding="utf-8") + native_object = project / "implementation.o" + archive = project / "libraw_math.a" + _run([shutil.which("gfortran"), "-fPIC", "-c", str(implementation), "-o", str(native_object)]) + _run([shutil.which("ar"), "rcs", str(archive), str(native_object)]) + _write_project( + project, + f"""prik_add_module( + raw_archive + SOURCES interface.f90 + NO_COMPILE_INPUT_SOURCES + LINKER_LANGUAGE Fortran + LINK_LIBRARIES "{archive.as_posix()}" +) +""", + ) + build = project / "build" + _configure_and_build(project, build, language="fortran") + + module = _import_extension("raw_archive", build) + assert module.raw_add_two(np.int32(5)) == np.int32(7) + + @pytest.mark.fortran_end_to_end @pytest.mark.skipif( shutil.which("cmake") is None or shutil.which("gfortran") is None or shutil.which("gcc") is None, @@ -645,3 +958,95 @@ def test_use_prik_cmake_links_a_cmake_discovered_blas_target(tmp_path: Path): _configure_and_build(project, build, language="fortran") module = _import_extension("blas_example", build) assert module.blas_dot(np.array([1.0, 2.0]), np.array([3.0, 4.0])) == np.float64(11.0) + + +@pytest.mark.fortran_end_to_end +@pytest.mark.slow +@pytest.mark.skipif( + shutil.which("cmake") is None + or shutil.which("gfortran") is None + or shutil.which("gcc") is None + or not SETUPTOOLS_SUPPORTS_PROJECT_DATA_FILES, + reason="CMake, compilers, and setuptools 77 or newer are required", +) +def test_installed_wheel_discovers_and_builds_with_use_prik(tmp_path: Path): + distribution_dir = tmp_path / "dist" + _run( + [ + sys.executable, + "-m", + "build", + "--wheel", + "--no-isolation", + "--outdir", + str(distribution_dir), + ], + cwd=REPOSITORY_ROOT, + ) + wheel = next(distribution_dir.glob("prik-*.whl")) + environment_dir = tmp_path / "installed" + venv.EnvBuilder(with_pip=True, system_site_packages=True).create(environment_dir) + installed_python = environment_dir / "bin" / "python" + clean_environment = os.environ.copy() + clean_environment.pop("PYTHONPATH", None) + _run( + [str(installed_python), "-m", "pip", "install", "--no-deps", str(wheel)], + environment=clean_environment, + ) + discovery = _run( + [ + str(installed_python), + "-I", + "-c", + "from prik.cmake import cmake_module_dir; print(cmake_module_dir() / 'UsePRIK.cmake')", + ], + environment=clean_environment, + ) + helper = Path(discovery.stdout.strip()) + assert helper.is_file() + assert REPOSITORY_ROOT not in helper.parents + + source = tmp_path / "installed_square.f90" + source.write_text( + "real(8) function installed_square(value) result(output)\n" + " real(8), intent(in) :: value\n" + " output = value * value\n" + "end function installed_square\n", + encoding="utf-8", + ) + project = tmp_path / "installed project" + _run( + [ + str(installed_python), + "-I", + "-m", + "prik", + "generate", + "--cmake", + str(source), + "--out-dir", + str(project), + ], + environment=clean_environment, + ) + build = project / "build" + _configure_and_build( + project, + build, + language="fortran", + environment=clean_environment, + python_executable=installed_python, + ) + artifact = next(build.rglob("installed_square*.so")) + imported = _run( + [ + str(installed_python), + "-I", + "-c", + f"import sys; sys.path.insert(0, {str(artifact.parent)!r}); " + "import installed_square; assert installed_square.installed_square(3.0) == 9.0", + ], + cwd=artifact.parent, + environment=clean_environment, + ) + assert imported.returncode == 0 diff --git a/tests/fortran/infrastructure/building/pipeline/test_generated_wrapper_build.py b/tests/fortran/infrastructure/building/pipeline/test_generated_wrapper_build.py index b5c4aff4f..c3faec8c9 100644 --- a/tests/fortran/infrastructure/building/pipeline/test_generated_wrapper_build.py +++ b/tests/fortran/infrastructure/building/pipeline/test_generated_wrapper_build.py @@ -39,6 +39,9 @@ def compile_object(self, object_file, *, verbose=False): module_file = object_file.object_path.parent / f"{object_file.source.stem}.mod" module_file.write_text("fortran module\n", encoding="utf-8") + def required_abi_flags(self, language): + return ("-frequired-abi",) if language == "fortran" else () + def link_extension( self, *, @@ -176,6 +179,24 @@ def scale(x: Float64) -> Float64: ... assert result.build_makefile is None assert result.native_build_plan == native_plan assert result.generated_sources == (bridge_source, binding_source, header) + assert [unit.to_dict() for unit in result.generated_compilation_units] == [ + { + "source": str(bridge_source), + "language": "fortran", + "include_dirs": [str(native_dir)], + "flags": ["-O2"], + "abi_flags": ["-frequired-abi"], + }, + { + "source": str(binding_source), + "language": "c", + "include_dirs": [str(native_dir)], + "flags": ["-O3"], + "abi_flags": [], + }, + ] + assert result.linker_language == "fortran" + assert result.extension_link_flags == ("-O3",) assert bridge_obj.object_path in result.generated_files assert binding_obj.object_path in result.generated_files assert native_support_header in result.generated_files diff --git a/tests/fortran/infrastructure/building/pipeline/test_pyi_build_modes.py b/tests/fortran/infrastructure/building/pipeline/test_pyi_build_modes.py index 778218f39..097b953d7 100644 --- a/tests/fortran/infrastructure/building/pipeline/test_pyi_build_modes.py +++ b/tests/fortran/infrastructure/building/pipeline/test_pyi_build_modes.py @@ -216,7 +216,7 @@ def test_pyi_makefile_manifest_and_replay_workflows(tmp_path: Path): assert manifest_path == build_dir / "prik-build.json" assert makefile_path == build_dir / "Makefile.prik" assert manifest == payload["manifest"] - assert manifest["schema_version"] == 4 + assert manifest["schema_version"] == 5 assert manifest["build_kind"] == "pyi-wrapper" assert manifest["compiler"]["input_executable"] == str(selected_compiler) assert Path(manifest["compiler"]["input_c_executable"]).resolve() == Path(shutil.which("gcc")).resolve() diff --git a/tests/fortran/infrastructure/cli/pipeline/test_argument_contract.py b/tests/fortran/infrastructure/cli/pipeline/test_argument_contract.py index 2aa899086..405ebc92b 100644 --- a/tests/fortran/infrastructure/cli/pipeline/test_argument_contract.py +++ b/tests/fortran/infrastructure/cli/pipeline/test_argument_contract.py @@ -408,7 +408,7 @@ def test_manifest_compiler_override_targets_only_its_recorded_native_language( manifest.write_text( json.dumps( { - "schema_version": 4, + "schema_version": 5, "build_kind": "pyi-wrapper", "extension": {"native_language": native_language}, } From d8db408ff18467adc7c047572681aebec804ba41 Mon Sep 17 00:00:00 2001 From: said Date: Thu, 10 Sep 2026 18:56:51 +0100 Subject: [PATCH 3/5] codex: fix remaining CMake integration issues --- CHANGELOG.md | 4 + cmake/UsePRIK.cmake | 107 +++++-- docs/developer/packages/pipeline.md | 7 +- docs/user/guide/cmake.md | 28 +- prik/cmake.py | 1 + .../building/end_to_end/test_cmake_builds.py | 264 ++++++++++++++++-- 6 files changed, 373 insertions(+), 38 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 16eff765a..217a46d2b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,10 @@ release tags add a leading `v` to the package version. compiler-required ABI options, preprocessing dependencies, and linker language from PRIK's completed build plan. +- CMake contract modules now distinguish semantic `NATIVE_LANGUAGE` from the + final `LINKER_LANGUAGE`, keep native compilation flags target-local, and + report a clear error when CMake's C language is not enabled. + - `prik-build.json` schema 5 records generated/native compilation-unit ABI flags and explicit native linker-language requirements. diff --git a/cmake/UsePRIK.cmake b/cmake/UsePRIK.cmake index a2f34805a..80b27301d 100644 --- a/cmake/UsePRIK.cmake +++ b/cmake/UsePRIK.cmake @@ -116,17 +116,26 @@ function(_prik_apply_compilation_unit target json group index generated source_r ) list(APPEND _prik_rebased_include_dirs "${_prik_rebased_include_dir}") endforeach() - if(_prik_unit_flags) - set_property( - SOURCE "${_prik_unit_source}" - APPEND PROPERTY COMPILE_OPTIONS ${_prik_unit_flags} - ) - endif() if(_prik_unit_language STREQUAL "fortran") set(_prik_cmake_unit_language Fortran) else() set(_prik_cmake_unit_language C) endif() + if(_prik_unit_flags) + if(generated) + set_property( + SOURCE "${_prik_unit_source}" + APPEND PROPERTY COMPILE_OPTIONS ${_prik_unit_flags} + ) + else() + foreach(_prik_unit_flag IN LISTS _prik_unit_flags) + target_compile_options( + "${target}" PRIVATE + "$<$:${_prik_unit_flag}>" + ) + endforeach() + endif() + endif() foreach(_prik_abi_flag IN LISTS _prik_unit_abi_flags) set(_prik_abi_key "${_prik_cmake_unit_language}:${_prik_abi_flag}") get_property(_prik_applied_abi_flags TARGET "${target}" PROPERTY _PRIK_APPLIED_ABI_FLAGS) @@ -138,10 +147,14 @@ function(_prik_apply_compilation_unit target json group index generated source_r endif() endforeach() if(_prik_rebased_include_dirs) - set_property( - SOURCE "${_prik_unit_source}" - APPEND PROPERTY INCLUDE_DIRECTORIES ${_prik_rebased_include_dirs} - ) + if(generated) + set_property( + SOURCE "${_prik_unit_source}" + APPEND PROPERTY INCLUDE_DIRECTORIES ${_prik_rebased_include_dirs} + ) + else() + target_include_directories("${target}" PRIVATE ${_prik_rebased_include_dirs}) + endif() endif() endfunction() @@ -162,7 +175,7 @@ function(prik_add_module name) endif() set(_options NO_COMPILE_INPUT_SOURCES NO_STANDARD_LOGICALS) - set(_one_value_arguments CONTRACT LINKER_LANGUAGE) + set(_one_value_arguments CONTRACT NATIVE_LANGUAGE LINKER_LANGUAGE) set(_multi_value_arguments SOURCES FORTRAN_SOURCES @@ -183,6 +196,14 @@ function(prik_add_module name) endif() _prik_validate_args(PRIK_PRIK_ARGS) + if(NOT CMAKE_C_COMPILER OR NOT CMAKE_C_COMPILER_ID) + message( + FATAL_ERROR + "PRIK Python extensions require CMake's C language to be enabled. " + "Use project(... LANGUAGES C Fortran) or enable_language(C)." + ) + endif() + if(PRIK_CONTRACT AND PRIK_SOURCES) message(FATAL_ERROR "prik_add_module(${name}) cannot combine CONTRACT and SOURCES") endif() @@ -209,19 +230,45 @@ function(prik_add_module name) message(FATAL_ERROR "prik_add_module(${name}) LINKER_LANGUAGE must be C or Fortran") endif() endif() + if(PRIK_NATIVE_LANGUAGE) + string(TOLOWER "${PRIK_NATIVE_LANGUAGE}" _prik_native_language) + if(NOT _prik_native_language STREQUAL "c" AND NOT _prik_native_language STREQUAL "fortran") + message(FATAL_ERROR "prik_add_module(${name}) NATIVE_LANGUAGE must be C or Fortran") + endif() + if(NOT _prik_contract) + message(FATAL_ERROR "prik_add_module(${name}) NATIVE_LANGUAGE is only valid with CONTRACT") + endif() + endif() if(_prik_contract) set(_prik_wrapper_sources) set(_prik_native_fortran_sources ${_prik_fortran_sources}) set(_prik_native_c_sources ${_prik_c_sources}) - if(_prik_native_fortran_sources) + if(_prik_native_language) + set(_prik_language "${_prik_native_language}") + elseif(_prik_native_fortran_sources AND _prik_native_c_sources) + message( + FATAL_ERROR + "prik_add_module(${name}) CONTRACT with mixed Fortran and C implementation " + "sources requires NATIVE_LANGUAGE" + ) + elseif(_prik_native_fortran_sources) set(_prik_language fortran) elseif(_prik_native_c_sources) set(_prik_language c) - elseif(PRIK_LINK_LIBRARIES AND _prik_linker_language) - set(_prik_language "${_prik_linker_language}") else() - message(FATAL_ERROR "PRIK contract module ${name} requires native sources, or LINK_LIBRARIES with LINKER_LANGUAGE") + message( + FATAL_ERROR + "prik_add_module(${name}) source-free CONTRACT requires NATIVE_LANGUAGE " + "plus LINK_LIBRARIES" + ) + endif() + if(NOT _prik_native_fortran_sources AND NOT _prik_native_c_sources AND NOT PRIK_LINK_LIBRARIES) + message( + FATAL_ERROR + "prik_add_module(${name}) source-free CONTRACT requires LINK_LIBRARIES " + "or native implementation sources" + ) endif() else() if(_prik_sources) @@ -254,6 +301,14 @@ function(prik_add_module name) _prik_validate_source_suffixes(fortran _prik_native_fortran_sources) _prik_validate_source_suffixes(c _prik_native_c_sources) + if(_prik_linker_language STREQUAL "fortran" AND NOT CMAKE_Fortran_COMPILER) + message( + FATAL_ERROR + "PRIK module ${name} requires CMake's Fortran language to be enabled for LINKER_LANGUAGE Fortran. " + "Use project(... LANGUAGES C Fortran) or enable_language(Fortran)." + ) + endif() + set(_prik_output_dir "${CMAKE_CURRENT_BINARY_DIR}/prik/${name}") file(MAKE_DIRECTORY "${_prik_output_dir}") @@ -372,6 +427,21 @@ function(prik_add_module name) endforeach() endif() + set(_prik_native_target) + if(_prik_native_target_sources) + set(_prik_native_target "prik_${name}_native_objects") + if(TARGET "${_prik_native_target}") + message(FATAL_ERROR "PRIK internal target already exists: ${_prik_native_target}") + endif() + add_library("${_prik_native_target}" OBJECT ${_prik_native_target_sources}) + set_target_properties("${_prik_native_target}" PROPERTIES POSITION_INDEPENDENT_CODE ON) + if(_prik_required_linker_language STREQUAL "fortran") + set_target_properties( + "${_prik_native_target}" PROPERTIES Fortran_MODULE_DIRECTORY "${_prik_output_dir}" + ) + endif() + endif() + execute_process( COMMAND "${Python_EXECUTABLE}" -c "import numpy; print(numpy.get_include())" RESULT_VARIABLE _prik_numpy_result @@ -422,8 +492,9 @@ function(prik_add_module name) if(_prik_additional_outputs) target_sources("${name}" PRIVATE ${_prik_additional_outputs}) endif() - if(_prik_native_target_sources) - target_sources("${name}" PRIVATE ${_prik_native_target_sources}) + if(_prik_native_target) + target_sources("${name}" PRIVATE "$") + add_dependencies("${name}" "${_prik_native_target}") endif() if(_prik_generated_unit_count GREATER 0) math(EXPR _prik_last_generated_unit_index "${_prik_generated_unit_count} - 1") @@ -438,7 +509,7 @@ function(prik_add_module name) math(EXPR _prik_last_native_unit_index "${_prik_native_unit_count} - 1") foreach(_prik_native_unit_index RANGE 0 ${_prik_last_native_unit_index}) _prik_apply_compilation_unit( - "${name}" "${_prik_plan_json}" native ${_prik_native_unit_index} FALSE + "${_prik_native_target}" "${_prik_plan_json}" native ${_prik_native_unit_index} FALSE "${_prik_plan_dir}" "${_prik_output_dir}" ) endforeach() diff --git a/docs/developer/packages/pipeline.md b/docs/developer/packages/pipeline.md index 80b8e4b84..25962ae45 100644 --- a/docs/developer/packages/pipeline.md +++ b/docs/developer/packages/pipeline.md @@ -119,8 +119,11 @@ Build integrations consume the completed result rather than compiler command logs. Native and generated compilation units retain their own requested flags, required ABI flags, and include directories; the result also records every semantic/preprocessing dependency and the final linker language. This is the -lossless boundary used by `UsePRIK.cmake` for source properties, regeneration, -and linker-driver selection. +lossless boundary used by `UsePRIK.cmake` for regeneration, linker-driver +selection, and target-local compilation. CMake places native units in a +private per-module object target so source properties cannot leak between +PRIK extension targets; generated sources remain on the Python extension +target. `WrapperBuildResult` and saved `.pyi` manifests report each generated native group's kind, language, member keys, and source paths, so zero-source, diff --git a/docs/user/guide/cmake.md b/docs/user/guide/cmake.md index b8a7c69be..203435b1d 100644 --- a/docs/user/guide/cmake.md +++ b/docs/user/guide/cmake.md @@ -64,8 +64,29 @@ cmake --build build `prik_add_module()` also accepts `SOURCES` for source-first input, `CONTRACT` with `FORTRAN_SOURCES` or `C_SOURCES` for an authored semantic `.pyi`, -`INCLUDE_DIRS`, `MODULE_DIRS`, source-specific compile flags, +`INCLUDE_DIRS`, `MODULE_DIRS`, native and generated-source compile flag groups, `LINK_LIBRARIES`, `LINK_OPTIONS`, and additional generation-only `PRIK_ARGS`. +For a contract backed only by opaque native inputs, use `NATIVE_LANGUAGE` to +state the contract ABI language and `LINKER_LANGUAGE` to state the final CMake +linker driver independently: + +```cmake +prik_add_module( + c_api + CONTRACT api.pyi + NATIVE_LANGUAGE C + LINKER_LANGUAGE Fortran + LINK_LIBRARIES native_fortran_archive +) +``` + +When native source files use one language, PRIK infers `NATIVE_LANGUAGE` from +`FORTRAN_SOURCES` or `C_SOURCES`. Set it explicitly when the contract ABI +differs from the implementation source language or when both source languages +are present. A source-free contract must state it explicitly. CMake's C +language must be enabled because every PRIK extension contains generated C +binding code. + The flag groups remain separate: - `FORTRAN_FLAGS` and `C_FLAGS` apply only to user-owned native sources. @@ -76,6 +97,8 @@ The flag groups remain separate: PRIK adds compiler-profile flags required by its ABI plan to the affected Fortran sources. `NO_STANDARD_LOGICALS` disables PRIK's Intel/NVIDIA logical interoperability option when compatibility with prebuilt objects requires it. +Only mandatory ABI flags are exported from PRIK's plan; recommended compiler +profile options remain the CMake toolchain's responsibility. CMake build type, debug, and interprocedural-optimization settings remain normal CMake target properties; `PRIK_ARGS` rejects compiler and compilation options that would bypass those target settings. @@ -118,7 +141,8 @@ The same form accepts normal project targets such as `native_math` and Normal Fortran sources and targets carry their link-language requirements through CMake. For a raw archive or shared library whose language is otherwise opaque, add `LINKER_LANGUAGE Fortran`; PRIK records that requirement in its -plan and the extension uses CMake's Fortran linker driver. +plan and the extension uses CMake's Fortran linker driver. This is independent +of `NATIVE_LANGUAGE`, which controls semantic-contract interpretation. ## Standalone generated project diff --git a/prik/cmake.py b/prik/cmake.py index 4cb718ae0..655c21d9d 100644 --- a/prik/cmake.py +++ b/prik/cmake.py @@ -238,6 +238,7 @@ def _append_source_declarations( lines.append(" NO_COMPILE_INPUT_SOURCES") if inputs.contract is not None: lines.append(f" CONTRACT {_cmake_string(_relative_path(inputs.contract, base))}") + lines.append(f" NATIVE_LANGUAGE {language.capitalize()}") elif not no_compile_input_sources and not inputs.native_fortran and not inputs.native_c: keyword = "C_SOURCES" if language == "c" else "FORTRAN_SOURCES" _append_block(lines, keyword, inputs.semantic_sources, base=base) diff --git a/tests/fortran/infrastructure/building/end_to_end/test_cmake_builds.py b/tests/fortran/infrastructure/building/end_to_end/test_cmake_builds.py index 670a60b94..6ac3bb142 100644 --- a/tests/fortran/infrastructure/building/end_to_end/test_cmake_builds.py +++ b/tests/fortran/infrastructure/building/end_to_end/test_cmake_builds.py @@ -3,7 +3,6 @@ from __future__ import annotations import importlib -import importlib.metadata import json import os from pathlib import Path @@ -14,7 +13,6 @@ import venv import numpy as np -from packaging.version import Version import pytest @@ -34,7 +32,6 @@ BRIDGE_NATIVE = ( REPOSITORY_ROOT / "tests" / "fortran" / "functions" / "end_to_end" / "fixtures" / "native" / "free_external.f90" ) -SETUPTOOLS_SUPPORTS_PROJECT_DATA_FILES = Version(importlib.metadata.version("setuptools")) >= Version("77.0.3") def _environment() -> dict[str, str]: @@ -209,6 +206,39 @@ def test_use_prik_cmake_preserves_per_source_compile_flags(tmp_path: Path): assert "-DPRIK_NATIVE_C" not in binding_command +@pytest.mark.fortran_end_to_end +@pytest.mark.skipif( + shutil.which("cmake") is None or shutil.which("gfortran") is None or shutil.which("gcc") is None, + reason="CMake, gfortran, and gcc are required", +) +def test_use_prik_cmake_keeps_native_flags_target_local(tmp_path: Path): + project = tmp_path / "shared native source" + project.mkdir() + source = project / "common.f90" + source.write_text( + "real(8) function common_value(value) result(result)\n" + " real(8), intent(in) :: value\n" + " result = value\n" + "end function common_value\n", + encoding="utf-8", + ) + _write_project( + project, + """set(CMAKE_EXPORT_COMPILE_COMMANDS ON) +prik_add_module(first SOURCES common.f90 FORTRAN_FLAGS -DFIRST_MODULE) +prik_add_module(second SOURCES common.f90 FORTRAN_FLAGS -DSECOND_MODULE) +""", + ) + build = project / "build" + _configure_and_build(project, build, language="fortran") + + commands = json.loads((build / "compile_commands.json").read_text(encoding="utf-8")) + native_commands = [record["command"] for record in commands if Path(record["file"]).resolve() == source.resolve()] + assert len(native_commands) == 2 + assert any("-DFIRST_MODULE" in command and "-DSECOND_MODULE" not in command for command in native_commands) + assert any("-DSECOND_MODULE" in command and "-DFIRST_MODULE" not in command for command in native_commands) + + @pytest.mark.fortran_end_to_end @pytest.mark.skipif( shutil.which("cmake") is None or shutil.which("gfortran") is None or shutil.which("gcc") is None, @@ -370,6 +400,69 @@ def test_generate_cmake_preserves_ordered_native_link_items(tmp_path: Path): assert positions == tuple(sorted(positions)) +@pytest.mark.fortran_end_to_end +def test_generate_cmake_emits_contract_and_linker_languages_separately(tmp_path: Path): + contract = tmp_path / "api.pyi" + contract.write_text( + "from prik.contracts import Float64\ndef add(value: Float64) -> Float64: ...\n", encoding="utf-8" + ) + archive = tmp_path / "libimplementation.a" + archive.touch() + project = tmp_path / "contract project" + _run( + [ + sys.executable, + "-m", + "prik", + "generate", + "--cmake", + "--language", + "c", + str(contract), + "--native-objects", + str(archive), + "--native-linker-language", + "fortran", + "--out-dir", + str(project), + ] + ) + cmake_lists = (project / "CMakeLists.txt").read_text(encoding="utf-8") + assert "CONTRACT" in cmake_lists + assert "NATIVE_LANGUAGE C" in cmake_lists + assert "LINKER_LANGUAGE Fortran" in cmake_lists + + +@pytest.mark.fortran_end_to_end +def test_generate_cmake_preserves_contract_language_with_different_source_language(tmp_path: Path): + contract = tmp_path / "api.pyi" + contract.write_text( + "from prik.contracts import Float64\ndef add(value: Float64) -> Float64: ...\n", encoding="utf-8" + ) + implementation = tmp_path / "implementation.f90" + implementation.write_text("subroutine implementation()\nend subroutine implementation\n", encoding="utf-8") + project = tmp_path / "mixed language contract project" + _run( + [ + sys.executable, + "-m", + "prik", + "generate", + "--cmake", + "--language", + "c", + str(contract), + "--native-fortran-sources", + str(implementation), + "--out-dir", + str(project), + ] + ) + cmake_lists = (project / "CMakeLists.txt").read_text(encoding="utf-8") + assert "NATIVE_LANGUAGE C" in cmake_lists + assert "FORTRAN_SOURCES" in cmake_lists + + @pytest.mark.fortran_end_to_end def test_generate_cmake_keeps_compile_option_ownership_explicit(tmp_path: Path): source = tmp_path / "interface.f90" @@ -752,6 +845,43 @@ def test_use_prik_cmake_rejects_invalid_generation_ownership( assert expected_error_fragment in result.stdout + result.stderr +@pytest.mark.fortran_end_to_end +@pytest.mark.skipif( + shutil.which("cmake") is None or shutil.which("gfortran") is None, reason="CMake and gfortran are required" +) +def test_use_prik_cmake_requires_the_c_language(tmp_path: Path): + project = tmp_path / "fortran-only project" + project.mkdir() + (project / "source.f90").write_text( + "real(8) function source(value) result(result)\n" + " real(8), intent(in) :: value\n" + " result = value\n" + "end function source\n", + encoding="utf-8", + ) + _write_project( + project, + """prik_add_module(source SOURCES source.f90) +""", + languages="Fortran", + ) + result = subprocess.run( + [ + "cmake", + "-S", + str(project), + "-B", + str(project / "build"), + f"-DCMAKE_Fortran_COMPILER={shutil.which('gfortran')}", + ], + env=_environment(), + capture_output=True, + text=True, + ) + assert result.returncode != 0 + assert "PRIK Python extensions require CMake's C language to be enabled" in result.stdout + result.stderr + + @pytest.mark.fortran_end_to_end @pytest.mark.skipif( shutil.which("cmake") is None or shutil.which("gfortran") is None or shutil.which("gcc") is None, @@ -877,6 +1007,89 @@ def test_use_prik_cmake_selects_fortran_linker_for_raw_archive(tmp_path: Path): assert module.raw_add_two(np.int32(5)) == np.int32(7) +@pytest.mark.fortran_end_to_end +@pytest.mark.skipif( + shutil.which("cmake") is None + or shutil.which("gfortran") is None + or shutil.which("gcc") is None + or shutil.which("ar") is None, + reason="CMake, gfortran, gcc, and ar are required", +) +def test_use_prik_cmake_separates_c_contract_and_fortran_linker_languages(tmp_path: Path): + project = tmp_path / "c contract with fortran implementation" + project.mkdir() + (project / "api.pyi").write_text( + "from prik.contracts import Float64\n\ndef add_one(value: Float64) -> Float64: ...\n", + encoding="utf-8", + ) + implementation = project / "implementation.f90" + implementation.write_text( + "real(c_double) function add_one(value) bind(C, name='add_one') result(result)\n" + " use iso_c_binding, only: c_double\n" + " real(c_double), value, intent(in) :: value\n" + " result = value + 1.0_c_double\n" + "end function add_one\n", + encoding="utf-8", + ) + native_object = project / "implementation.o" + archive = project / "libimplementation.a" + _run([shutil.which("gfortran"), "-fPIC", "-c", str(implementation), "-o", str(native_object)]) + _run([shutil.which("ar"), "rcs", str(archive), str(native_object)]) + _write_project( + project, + """prik_add_module( + c_contract + CONTRACT api.pyi + NATIVE_LANGUAGE C + LINKER_LANGUAGE Fortran + LINK_LIBRARIES "${CMAKE_CURRENT_SOURCE_DIR}/libimplementation.a" +) +""", + ) + build = project / "build" + _configure_and_build(project, build, language="fortran") + + module = _import_extension("c_contract", build) + assert module.add_one(np.float64(5.0)) == np.float64(6.0) + + +@pytest.mark.fortran_end_to_end +@pytest.mark.skipif( + shutil.which("cmake") is None or shutil.which("gfortran") is None or shutil.which("gcc") is None, + reason="CMake, gfortran, and gcc are required", +) +def test_use_prik_cmake_allows_contract_language_to_differ_from_source_language(tmp_path: Path): + project = tmp_path / "c contract with fortran source" + project.mkdir() + (project / "api.pyi").write_text( + "from prik.contracts import Float64\n\ndef add_two(value: Float64) -> Float64: ...\n", + encoding="utf-8", + ) + (project / "implementation.f90").write_text( + "real(c_double) function add_two(value) bind(C, name='add_two') result(result)\n" + " use iso_c_binding, only: c_double\n" + " real(c_double), value, intent(in) :: value\n" + " result = value + 2.0_c_double\n" + "end function add_two\n", + encoding="utf-8", + ) + _write_project( + project, + """prik_add_module( + c_contract_source + CONTRACT api.pyi + NATIVE_LANGUAGE C + FORTRAN_SOURCES implementation.f90 +) +""", + ) + build = project / "build" + _configure_and_build(project, build, language="fortran") + + module = _import_extension("c_contract_source", build) + assert module.add_two(np.float64(5.0)) == np.float64(7.0) + + @pytest.mark.fortran_end_to_end @pytest.mark.skipif( shutil.which("cmake") is None or shutil.which("gfortran") is None or shutil.which("gcc") is None, @@ -963,32 +1176,51 @@ def test_use_prik_cmake_links_a_cmake_discovered_blas_target(tmp_path: Path): @pytest.mark.fortran_end_to_end @pytest.mark.slow @pytest.mark.skipif( - shutil.which("cmake") is None - or shutil.which("gfortran") is None - or shutil.which("gcc") is None - or not SETUPTOOLS_SUPPORTS_PROJECT_DATA_FILES, - reason="CMake, compilers, and setuptools 77 or newer are required", + shutil.which("cmake") is None or shutil.which("gfortran") is None or shutil.which("gcc") is None, + reason="CMake, gfortran, and gcc are required", ) def test_installed_wheel_discovers_and_builds_with_use_prik(tmp_path: Path): distribution_dir = tmp_path / "dist" - _run( + clean_environment = os.environ.copy() + clean_environment.pop("PYTHONPATH", None) + wheel_build = subprocess.run( [ sys.executable, "-m", - "build", - "--wheel", - "--no-isolation", - "--outdir", + "pip", + "wheel", + "--no-deps", + "--wheel-dir", str(distribution_dir), + ".", ], cwd=REPOSITORY_ROOT, + env=clean_environment, + capture_output=True, + text=True, ) - wheel = next(distribution_dir.glob("prik-*.whl")) + if wheel_build.returncode != 0: + wheel_output = wheel_build.stderr.strip() or wheel_build.stdout.strip() + unavailable_markers = ( + "No module named pip", + "No module named build", + "No matching distribution found", + "Could not find a version that satisfies", + "Could not fetch URL", + "Temporary failure in name resolution", + "Network is unreachable", + "Connection timed out", + ) + if any(marker.lower() in wheel_output.lower() for marker in unavailable_markers): + pytest.skip(f"isolated wheel construction is unavailable: {wheel_output}") + pytest.fail(f"isolated wheel construction failed:\n{wheel_output}") + wheels = tuple(distribution_dir.glob("prik-*.whl")) + if not wheels: + pytest.skip("isolated wheel construction produced no wheel") + wheel = wheels[0] environment_dir = tmp_path / "installed" venv.EnvBuilder(with_pip=True, system_site_packages=True).create(environment_dir) installed_python = environment_dir / "bin" / "python" - clean_environment = os.environ.copy() - clean_environment.pop("PYTHONPATH", None) _run( [str(installed_python), "-m", "pip", "install", "--no-deps", str(wheel)], environment=clean_environment, From d049a0b2b778a0eabcf4ec57198e2bc01dec0f5a Mon Sep 17 00:00:00 2001 From: said Date: Thu, 10 Sep 2026 19:39:35 +0100 Subject: [PATCH 4/5] codex: fix CMake dependency usage and LTO --- CHANGELOG.md | 4 ++ cmake/UsePRIK.cmake | 5 +++ docs/developer/packages/pipeline.md | 4 +- docs/user/guide/cmake.md | 8 +++- prik/cmake.py | 19 ++++++---- .../building/end_to_end/test_cmake_builds.py | 38 ++++++++++++++++++- 6 files changed, 68 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 217a46d2b..996838f9a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,10 @@ release tags add a leading `v` to the package version. final `LINKER_LANGUAGE`, keep native compilation flags target-local, and report a clear error when CMake's C language is not enabled. +- CMake dependency targets now propagate their native compile usage + requirements, and standalone `--cmake --lto` initializes IPO for native and + generated targets. + - `prik-build.json` schema 5 records generated/native compilation-unit ABI flags and explicit native linker-language requirements. diff --git a/cmake/UsePRIK.cmake b/cmake/UsePRIK.cmake index 80b27301d..3b3b0ad96 100644 --- a/cmake/UsePRIK.cmake +++ b/cmake/UsePRIK.cmake @@ -435,6 +435,11 @@ function(prik_add_module name) endif() add_library("${_prik_native_target}" OBJECT ${_prik_native_target_sources}) set_target_properties("${_prik_native_target}" PROPERTIES POSITION_INDEPENDENT_CODE ON) + foreach(_prik_library IN LISTS PRIK_LINK_LIBRARIES) + if(TARGET "${_prik_library}") + target_link_libraries("${_prik_native_target}" PRIVATE "${_prik_library}") + endif() + endforeach() if(_prik_required_linker_language STREQUAL "fortran") set_target_properties( "${_prik_native_target}" PROPERTIES Fortran_MODULE_DIRECTORY "${_prik_output_dir}" diff --git a/docs/developer/packages/pipeline.md b/docs/developer/packages/pipeline.md index 25962ae45..7dd813ef3 100644 --- a/docs/developer/packages/pipeline.md +++ b/docs/developer/packages/pipeline.md @@ -123,7 +123,9 @@ lossless boundary used by `UsePRIK.cmake` for regeneration, linker-driver selection, and target-local compilation. CMake places native units in a private per-module object target so source properties cannot leak between PRIK extension targets; generated sources remain on the Python extension -target. +target. CMake dependency targets stay attached to the extension for linking +and are also attached to that object target when present, so their usage +requirements reach native compilation. `WrapperBuildResult` and saved `.pyi` manifests report each generated native group's kind, language, member keys, and source paths, so zero-source, diff --git a/docs/user/guide/cmake.md b/docs/user/guide/cmake.md index 203435b1d..a48265a01 100644 --- a/docs/user/guide/cmake.md +++ b/docs/user/guide/cmake.md @@ -100,7 +100,10 @@ interoperability option when compatibility with prebuilt objects requires it. Only mandatory ABI flags are exported from PRIK's plan; recommended compiler profile options remain the CMake toolchain's responsibility. CMake build type, debug, and interprocedural-optimization settings remain -normal CMake target properties; `PRIK_ARGS` rejects compiler and compilation +normal CMake target properties; set +`CMAKE_INTERPROCEDURAL_OPTIMIZATION` before `prik_add_module()` when IPO should +cover both native and generated sources. Standalone `--cmake --lto` emits that +initializer automatically. `PRIK_ARGS` rejects compiler and compilation options that would bypass those target settings. Use `NO_COMPILE_INPUT_SOURCES` when `SOURCES` supplies only the public @@ -138,6 +141,9 @@ prik_add_module( The same form accepts normal project targets such as `native_math` and `OpenMP::OpenMP_Fortran`; they remain target-oriented CMake link inputs. +When a linked entry is a CMake target, its compile and include usage +requirements also reach PRIK's private native object target. Raw library paths +retain link behavior but do not provide CMake usage requirements. Normal Fortran sources and targets carry their link-language requirements through CMake. For a raw archive or shared library whose language is otherwise opaque, add `LINKER_LANGUAGE Fortran`; PRIK records that requirement in its diff --git a/prik/cmake.py b/prik/cmake.py index 655c21d9d..5c48694da 100644 --- a/prik/cmake.py +++ b/prik/cmake.py @@ -196,8 +196,8 @@ def _module_inputs(*, paths: Iterable[str | Path], args) -> _CMakeModuleInputs: ) -def _project_preamble(*, module_name: str, languages: str) -> list[str]: - return [ +def _project_preamble(*, module_name: str, languages: str, lto: bool = False) -> list[str]: + lines = [ "cmake_minimum_required(VERSION 3.20)", "", f"project({module_name} LANGUAGES {languages})", @@ -221,9 +221,16 @@ def _project_preamble(*, module_name: str, languages: str) -> list[str]: 'list(APPEND CMAKE_MODULE_PATH "${PRIK_CMAKE_MODULE_DIR}")', "include(UsePRIK)", "", - "prik_add_module(", - f" {module_name}", ] + if lto: + lines.extend(("set(CMAKE_INTERPROCEDURAL_OPTIMIZATION TRUE)", "")) + lines.extend( + [ + "prik_add_module(", + f" {module_name}", + ] + ) + return lines def _append_source_declarations( @@ -293,7 +300,7 @@ def write_cmake_project( if language == "fortran" or inputs.native_fortran or args.native_linker_language == "fortran" else "C" ) - lines = _project_preamble(module_name=inputs.module_name, languages=project_languages) + lines = _project_preamble(module_name=inputs.module_name, languages=project_languages, lto=args.lto) _append_source_declarations( lines, inputs, @@ -303,8 +310,6 @@ def write_cmake_project( ) _append_build_options(lines, args=args, project_dir=project_dir, native_link_items=native_link_items) lines.append(")") - if args.lto: - lines.extend(("", f"set_property(TARGET {inputs.module_name} PROPERTY INTERPROCEDURAL_OPTIMIZATION TRUE)")) cmake_lists = project_dir / "CMakeLists.txt" cmake_lists.write_text("\n".join(lines) + "\n", encoding="utf-8") diff --git a/tests/fortran/infrastructure/building/end_to_end/test_cmake_builds.py b/tests/fortran/infrastructure/building/end_to_end/test_cmake_builds.py index 6ac3bb142..3d076a7f8 100644 --- a/tests/fortran/infrastructure/building/end_to_end/test_cmake_builds.py +++ b/tests/fortran/infrastructure/building/end_to_end/test_cmake_builds.py @@ -499,7 +499,10 @@ def test_generate_cmake_keeps_compile_option_ownership_explicit(tmp_path: Path): assert 'WRAPPER_C_FLAGS\n "-DPRIK_WRAPPER_C"' in cmake_lists assert "LINKER_LANGUAGE Fortran" in cmake_lists assert "NO_STANDARD_LOGICALS" in cmake_lists - assert "INTERPROCEDURAL_OPTIMIZATION TRUE" in cmake_lists + lto_initializer = "set(CMAKE_INTERPROCEDURAL_OPTIMIZATION TRUE)" + assert lto_initializer in cmake_lists + assert cmake_lists.index(lto_initializer) < cmake_lists.index("prik_add_module(") + assert "set_property(TARGET explicit_options PROPERTY INTERPROCEDURAL_OPTIMIZATION TRUE)" not in cmake_lists assert "--compiler" not in cmake_lists @@ -797,6 +800,39 @@ def test_use_prik_cmake_builds_c_source_with_include_directory_and_flag(tmp_path assert module.c_add(np.float64(2.0), np.float64(3.0)) == np.float64(6.0) +@pytest.mark.fortran_end_to_end +@pytest.mark.skipif(shutil.which("cmake") is None or shutil.which("gcc") is None, reason="CMake and gcc are required") +def test_use_prik_cmake_propagates_dependency_usage_to_native_objects(tmp_path: Path): + project = tmp_path / "native dependency usage" + project.mkdir() + (project / "interface.c").write_text("double dependency_add(double value);\n", encoding="utf-8") + (project / "implementation.c").write_text( + "#ifndef PRIK_REQUIRED_DEFINE\n" + "#error missing dependency compile definition\n" + "#endif\n" + "double dependency_add(double value) { return value + 1.0; }\n", + encoding="utf-8", + ) + _write_project( + project, + """add_library(native_dependency INTERFACE) +target_compile_definitions(native_dependency INTERFACE PRIK_REQUIRED_DEFINE) +prik_add_module( + dependency_usage + SOURCES interface.c + C_SOURCES implementation.c + LINK_LIBRARIES native_dependency +) +""", + languages="C", + ) + build = project / "build" + _configure_and_build(project, build, language="c") + + module = _import_extension("dependency_usage", build) + assert module.dependency_add(np.float64(2.0)) == np.float64(3.0) + + @pytest.mark.fortran_end_to_end @pytest.mark.skipif( shutil.which("cmake") is None or shutil.which("gfortran") is None or shutil.which("gcc") is None, From 96bf11302890b05dfd2d7a80f9c9d813f820f87e Mon Sep 17 00:00:00 2001 From: said Date: Thu, 10 Sep 2026 20:03:26 +0100 Subject: [PATCH 5/5] codex: harden CMake language validation --- cmake/UsePRIK.cmake | 25 +++++++++++-------- docs/user/reference/cli-commands.md | 5 ++-- prik/cli.py | 2 +- .../building/end_to_end/test_cmake_builds.py | 1 + 4 files changed, 20 insertions(+), 13 deletions(-) diff --git a/cmake/UsePRIK.cmake b/cmake/UsePRIK.cmake index 3b3b0ad96..a4ee6ac13 100644 --- a/cmake/UsePRIK.cmake +++ b/cmake/UsePRIK.cmake @@ -196,7 +196,10 @@ function(prik_add_module name) endif() _prik_validate_args(PRIK_PRIK_ARGS) - if(NOT CMAKE_C_COMPILER OR NOT CMAKE_C_COMPILER_ID) + get_property(_prik_enabled_languages GLOBAL PROPERTY ENABLED_LANGUAGES) + list(FIND _prik_enabled_languages C _prik_c_language_index) + list(FIND _prik_enabled_languages Fortran _prik_fortran_language_index) + if(_prik_c_language_index EQUAL -1 OR NOT CMAKE_C_COMPILER) message( FATAL_ERROR "PRIK Python extensions require CMake's C language to be enabled. " @@ -301,25 +304,27 @@ function(prik_add_module name) _prik_validate_source_suffixes(fortran _prik_native_fortran_sources) _prik_validate_source_suffixes(c _prik_native_c_sources) - if(_prik_linker_language STREQUAL "fortran" AND NOT CMAKE_Fortran_COMPILER) - message( - FATAL_ERROR - "PRIK module ${name} requires CMake's Fortran language to be enabled for LINKER_LANGUAGE Fortran. " - "Use project(... LANGUAGES C Fortran) or enable_language(Fortran)." - ) + if(_prik_linker_language STREQUAL "fortran") + if(_prik_fortran_language_index EQUAL -1 OR NOT CMAKE_Fortran_COMPILER) + message( + FATAL_ERROR + "PRIK module ${name} requires CMake's Fortran language to be enabled for LINKER_LANGUAGE Fortran. " + "Use project(... LANGUAGES C Fortran) or enable_language(Fortran)." + ) + endif() endif() set(_prik_output_dir "${CMAKE_CURRENT_BINARY_DIR}/prik/${name}") file(MAKE_DIRECTORY "${_prik_output_dir}") if(_prik_language STREQUAL "fortran") + if(_prik_fortran_language_index EQUAL -1 OR NOT CMAKE_Fortran_COMPILER) + message(FATAL_ERROR "PRIK module ${name} requires CMake to enable Fortran") + endif() set(_prik_analysis_compiler "${CMAKE_Fortran_COMPILER}") else() set(_prik_analysis_compiler "${CMAKE_C_COMPILER}") endif() - if(NOT _prik_analysis_compiler) - message(FATAL_ERROR "PRIK module ${name} requires CMake to enable ${_prik_language}") - endif() set(_prik_generate_command "${Python_EXECUTABLE}" -m prik generate --sources) if(_prik_contract) diff --git a/docs/user/reference/cli-commands.md b/docs/user/reference/cli-commands.md index eb01a116e..c6738638f 100644 --- a/docs/user/reference/cli-commands.md +++ b/docs/user/reference/cli-commands.md @@ -100,7 +100,7 @@ least one explicit native input: `--native-fortran-sources`, `--native-c-sources | `--native-link-item KIND:VALUE ...` | Ordered link items. `KIND` is `object`, `archive`, `shared-library`, `library`, or `arg`. | | `--native-linker-language {c,fortran}` | Requires the named final linker language when prebuilt inputs do not carry it. | | `--native-library-dir DIR ...` | Library search directories and runtime paths. | -| `--lto` | Enables link-time optimization for Fortran and C builds by adding `-flto` to generated and native compilation and to the extension link. | +| `--lto` | Enables link-time optimization for generated and native compilation and the extension link. Direct builds add `-flto`; generated CMake projects initialize CMake IPO. | | `--collision-adapter NAME ...` | Calls native symbol `NAME` through a forwarder defined in a separate translation unit, so the binding never declares an identifier its own headers already declare. | | `--collision-adapter-all` | Applies `--collision-adapter` to every eligible C function in the build. | | `--positional-only` | For Fortran and C, exposes every wrapper whose arguments are all required as positional-only, renaming them `arg0`..`argN`. | @@ -231,7 +231,8 @@ In CMake mode, `--compiler` and `--wrapper-compiler-debug` are rejected: CMake's selected compiler and build configuration own those choices. Native and generated-wrapper flag options remain distinct in the generated helper call, `--no-standard-logicals` maps to PRIK's CMake compilation plan, and -`--lto` maps to CMake's interprocedural-optimization target property. +`--lto` initializes CMake interprocedural optimization before PRIK creates its +native and extension targets. With no `--out`, `generate --pyi` prints every generated contract. For Fortran, `--out PATH` names a package directory containing `__init__.pyi` and any module leaves. For C, it names the single output `.pyi` diff --git a/prik/cli.py b/prik/cli.py index 86cc92a31..a9ca96ef2 100644 --- a/prik/cli.py +++ b/prik/cli.py @@ -2363,7 +2363,7 @@ def _add_extension_link_options(group: argparse._ArgumentGroup) -> None: group.add_argument( "--lto", action="store_true", - help="Add -flto to generated and native compilation and to the extension link", + help="Enable link-time optimization for generated and native compilation and the extension link", ) group.add_argument( "--collision-adapter", diff --git a/tests/fortran/infrastructure/building/end_to_end/test_cmake_builds.py b/tests/fortran/infrastructure/building/end_to_end/test_cmake_builds.py index 3d076a7f8..245ca7aa1 100644 --- a/tests/fortran/infrastructure/building/end_to_end/test_cmake_builds.py +++ b/tests/fortran/infrastructure/building/end_to_end/test_cmake_builds.py @@ -908,6 +908,7 @@ def test_use_prik_cmake_requires_the_c_language(tmp_path: Path): str(project), "-B", str(project / "build"), + f"-DCMAKE_C_COMPILER={shutil.which('gcc')}", f"-DCMAKE_Fortran_COMPILER={shutil.which('gfortran')}", ], env=_environment(),