From 9d729808c9b21fa188720443852d9eaf74cd331f Mon Sep 17 00:00:00 2001 From: said Date: Sun, 13 Sep 2026 13:10:25 +0100 Subject: [PATCH 1/4] codex: resolve PRIK through scikit-build-core's cmake.root A scikit-build-core project had to include(UsePRIK), because cmake.module was the only entry point PRIK published. It now also publishes cmake.root, whose entry-point name scikit-build-core turns into a _ROOT cache variable, so the name must stay PRIK: that is what lets a project listing PRIK in build-system.requires resolve find_package(PRIK CONFIG REQUIRED) with no PRIK_DIR, CMAKE_PREFIX_PATH, or CMAKE_MODULE_PATH. include(UsePRIK) keeps working through cmake.module, and PRIK_DIR, CMAKE_PREFIX_PATH, cmake-dir, and install-dir are unchanged. A configure-time structural query that cannot import PRIK or one of its dependencies now names the selected Python_EXECUTABLE and how to check it, keeping the underlying error. The hint is attached only to an import failure, so a real generation error still reaches the user as itself. prik doctor cmake reports what a build system would discover: the imported package, the metadata answering for it, cmake-dir, install-dir, both entry points, and any duplicate distribution, PYTHONPATH copy, or metadata that does not describe the imported package. The example defaults to find_package so its scikit-build-core route exercises the preferred form, and the primary regression test is now the end-user path: a fresh environment, build isolation on, PRIK reaching the build only through build-system.requires, then installing and calling the built extension. The existing --no-build-isolation route stays, since it builds against a checkout. Docs, CHANGELOG, packaging tests, and diagnostics tests are part of the same change, and the guide's prose covers all of it, so this lands as one commit. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KMz3kPhNruZUmNkDAMT9KJ --- CHANGELOG.md | 18 ++- docs/user/guide/cmake.md | 29 +++- docs/user/reference/cli-commands.md | 18 +++ examples/cmake/CMakeLists.txt | 22 +-- examples/cmake/README.md | 24 +++- prik/cli.py | 39 +++++- prik/cmake_modules/UsePRIK.cmake | 27 +++- prik/installation.py | 85 ++++++++++++ pyproject.toml | 6 + .../_support/installed_distribution.py | 28 ++-- .../building/end_to_end/test_cmake_builds.py | 129 +++++++++++++++++- .../building/pipeline/test_cmake_packaging.py | 25 ++++ .../cli/pipeline/test_stage_dispatch.py | 19 +++ 13 files changed, 438 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 18b51ad4c..ed7a495a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -72,10 +72,26 @@ release tags add a leading `v` to the package version. Ninja as a warning because those tests then use the Makefile generator. - Added the runnable `examples/cmake/` project, which builds one Fortran module - through every CMake discovery route -- scikit-build-core's entry point, + through every CMake discovery route -- scikit-build-core's entry points, `CMAKE_MODULE_PATH`, `PRIK_DIR`, and an installation prefix -- with a script that runs each route and calls the built extension. +- PRIK also publishes scikit-build-core's `cmake.root` entry point, which sets + `PRIK_ROOT`, so a project listing PRIK in `[build-system] requires` resolves + `find_package(PRIK CONFIG REQUIRED)` with no `PRIK_DIR`, `CMAKE_PREFIX_PATH`, + or `CMAKE_MODULE_PATH`. `include(UsePRIK)` keeps working through the existing + `cmake.module` entry point. + +- A configure-time structural query that fails because PRIK or one of its + dependencies cannot be imported now names the selected `Python_EXECUTABLE` + and how to check it, while keeping the underlying error. Other generation + failures are reported unchanged. + +- Added `prik doctor cmake`, which reports the imported package, the + distribution metadata answering for it, `cmake-dir`, `install-dir`, both + CMake entry points, and any duplicate installation or `PYTHONPATH` entry that + could answer instead. + - Array handles support allocatable and pointer arguments, results, module variables, derived fields, optional arguments, and matching ordinary-array parameters. Numeric and character arrays accept supported forward and diff --git a/docs/user/guide/cmake.md b/docs/user/guide/cmake.md index f36cc78e7..71772b9ce 100644 --- a/docs/user/guide/cmake.md +++ b/docs/user/guide/cmake.md @@ -70,12 +70,20 @@ helper, and these routes differ only in how CMake reaches it: | Packaged directory | `find_package(PRIK CONFIG REQUIRED)` | `-DPRIK_DIR="$(prik cmake-dir)"` | | Installation prefix | `find_package(PRIK CONFIG REQUIRED)` | `-DCMAKE_PREFIX_PATH="$(prik install-dir)"` | | Module path | `include(UsePRIK)` | `-DCMAKE_MODULE_PATH="$(prik cmake-dir)"` | -| scikit-build-core | `include(UsePRIK)` | nothing; the backend reads PRIK's `cmake.module` entry point | +| scikit-build-core | `find_package(PRIK CONFIG REQUIRED)` | nothing at all | `PRIK_DIR` is package-specific, so setting it does not affect how other CMake packages are found; `CMAKE_PREFIX_PATH` is the broader search path every `find_package()` call shares. +PRIK publishes both of scikit-build-core's discovery entry points, so either +project form works there with nothing on the command line: + +| Entry point | What the backend sets | What the project calls | +| --- | --- | --- | +| `cmake.root` | `PRIK_ROOT` | `find_package(PRIK CONFIG REQUIRED)` | +| `cmake.module` | `CMAKE_MODULE_PATH` | `include(UsePRIK)` | + `prik install-dir` prints the prefix this PRIK's own installation wrote its data files under, which carries the same modules in `share/prik/cmake` and also resolves everything else installed there. The prefix comes from that @@ -93,17 +101,18 @@ requires = ["scikit-build-core>=0.10", "prik"] build-backend = "scikit_build_core.build" ``` -The backend then puts PRIK's packaged CMake directory on `CMAKE_MODULE_PATH` -itself, so `include(UsePRIK)` needs nothing on the command line and building -the wheel is one command: +The backend installs PRIK into its own build environment and reads PRIK's +entry points from there, so the project keeps the same +`find_package(PRIK CONFIG REQUIRED)` it uses everywhere else, and building the +wheel takes no PRIK-specific argument: ```bash -python3 -m pip wheel . --no-deps --wheel-dir dist +python3 -m pip wheel . ``` The three command-line routes above use whichever `prik` the shell resolves. scikit-build-core instead uses the PRIK installed in its build environment, -which it finds through the `cmake.module` entry point. When the build must +which it finds through those entry points. When the build must match the interpreter CMake itself selected -- several environments on one machine, or a `Python_EXECUTABLE` the project pins -- ask that interpreter, which also needs no `-D` argument: @@ -127,6 +136,14 @@ include(UsePRIK) it generates, which is why that project configures with a plain `cmake -S . -B build`. +When a build cannot find PRIK, or finds one you did not expect, +`prik doctor cmake` reports what a build system would discover: the imported +package, the distribution metadata answering for it, `cmake-dir`, +`install-dir`, both entry points, and any duplicate installation or +`PYTHONPATH` entry that could answer instead. Run it through the interpreter in +question -- `"${Python_EXECUTABLE}" -m prik doctor cmake` -- to see what CMake +sees. + [`examples/cmake/`](../../../examples/cmake/README.md) is a runnable project that builds the same module through every route, with a script that checks each one in turn. diff --git a/docs/user/reference/cli-commands.md b/docs/user/reference/cli-commands.md index e6ea8fff9..d569c8d92 100644 --- a/docs/user/reference/cli-commands.md +++ b/docs/user/reference/cli-commands.md @@ -16,6 +16,7 @@ stages without building one, and two print paths another tool builds against. python3 -m prik INPUT [INPUT ...] [BUILD OPTIONS] python3 -m prik {parse,semantics,generate,probe} [OPTIONS] ... python3 -m prik {cmake-dir,install-dir} +python3 -m prik doctor cmake ``` | Command | Purpose | @@ -27,6 +28,7 @@ python3 -m prik {cmake-dir,install-dir} | `probe` | Prints compiler-target datatype and ABI facts. | | `cmake-dir` | Prints the directory holding PRIK's packaged CMake modules. | | `install-dir` | Prints the prefix holding PRIK's installed data files. | +| `doctor` | Reports how a build system would discover this PRIK installation. | ## Getting help @@ -309,6 +311,22 @@ cmake -S . -B build -DCMAKE_PREFIX_PATH="$(prik install-dir)" Both make `find_package(PRIK CONFIG REQUIRED)` resolve. See the [CMake builds guide](../guide/cmake.md) for the project side. +`doctor cmake` reports the same paths together with what resolved them, for +when a build finds no PRIK or the wrong one: + +```bash +python3 -m prik doctor cmake +``` + +| Line | Reports | +| --- | --- | +| `prik version`, `imported package` | The version the metadata records, and the package directory actually imported. | +| `python executable` | The interpreter answering, which is the one CMake selected when the report is run through it. | +| `cmake-dir`, `install-dir` | The same paths those commands print, or why there is no prefix. | +| `distribution metadata` | Where the metadata answering for `prik` lives. | +| `entry point cmake.root`, `entry point cmake.module` | What a build backend would discover, by name and directory. | +| `conflicts` | Duplicate `prik` distributions, a `PYTHONPATH` entry holding another copy, or metadata that does not describe the imported package. | + ## Compiler preprocessing These options control preprocessing before parsing. diff --git a/examples/cmake/CMakeLists.txt b/examples/cmake/CMakeLists.txt index a65e0685a..d755eb583 100644 --- a/examples/cmake/CMakeLists.txt +++ b/examples/cmake/CMakeLists.txt @@ -8,19 +8,19 @@ find_package( REQUIRED ) -set(PRIK_DISCOVERY "include" CACHE STRING "How this project loads PRIK's CMake modules") -set_property(CACHE PRIK_DISCOVERY PROPERTY STRINGS include find-package) +set(PRIK_DISCOVERY "find-package" CACHE STRING "How this project loads PRIK's CMake modules") +set_property(CACHE PRIK_DISCOVERY PROPERTY STRINGS find-package include) -if(PRIK_DISCOVERY STREQUAL "find-package") - # Needs PRIK's CMake package on a search path: PRIK_DIR set to - # `prik cmake-dir`, or CMAKE_PREFIX_PATH set to `prik install-dir`. - find_package(PRIK CONFIG REQUIRED) -else() - # scikit-build-core adds PRIK's packaged module directory to - # CMAKE_MODULE_PATH through its cmake.module entry point, so this include - # needs no lookup. A plain configure gets there by setting - # CMAKE_MODULE_PATH to `prik cmake-dir`. +if(PRIK_DISCOVERY STREQUAL "include") + # The module-path route. A plain configure needs CMAKE_MODULE_PATH set to + # `prik cmake-dir`; scikit-build-core supplies it from PRIK's cmake.module + # entry point. include(UsePRIK) +else() + # The default. scikit-build-core sets PRIK_ROOT from PRIK's cmake.root + # entry point, so this needs no argument there at all; a plain configure + # passes PRIK_DIR or CMAKE_PREFIX_PATH instead. + find_package(PRIK CONFIG REQUIRED) endif() prik_add_module( diff --git a/examples/cmake/README.md b/examples/cmake/README.md index 5bc83f872..503d4455c 100644 --- a/examples/cmake/README.md +++ b/examples/cmake/README.md @@ -11,7 +11,7 @@ and the full `prik_add_module()` surface. | File | Role | | --- | --- | | [`kernel.f90`](kernel.f90) | Fortran module with one diffusion step and one reduction | -| [`CMakeLists.txt`](CMakeLists.txt) | One `prik_add_module()` call; `PRIK_DISCOVERY` selects `include` or `find-package` | +| [`CMakeLists.txt`](CMakeLists.txt) | One `prik_add_module()` call; `PRIK_DISCOVERY` selects `find-package` (default) or `include` | | [`pyproject.toml`](pyproject.toml) | The same project as a scikit-build-core wheel | | [`check_discovery_routes.sh`](check_discovery_routes.sh) | Builds and calls the extension once per route | @@ -20,7 +20,12 @@ and the full `prik_add_module()` surface. | `module-path` | `include(UsePRIK)` | `-DCMAKE_MODULE_PATH="$(prik cmake-dir)"` | | `find-package-dir` | `find_package(PRIK CONFIG REQUIRED)` | `-DPRIK_DIR="$(prik cmake-dir)"` | | `install-prefix` | `find_package(PRIK CONFIG REQUIRED)` | `-DCMAKE_PREFIX_PATH="$(prik install-dir)"` | -| `scikit-build-core` | `include(UsePRIK)` | nothing: the build backend reads PRIK's `cmake.module` entry point | +| `scikit-build-core` | `find_package(PRIK CONFIG REQUIRED)` | nothing: the backend sets `PRIK_ROOT` from PRIK's `cmake.root` entry point | + +`find_package(PRIK CONFIG REQUIRED)` is the project's default here, so the +scikit-build-core route needs no argument at all. `PRIK_DISCOVERY=include` +selects `include(UsePRIK)` instead, which scikit-build-core also supports +through PRIK's `cmake.module` entry point. ## Requirements @@ -81,3 +86,18 @@ conserved = heat.kernel.total(values) # 1.0 Scalar arguments take NumPy scalars, which is PRIK's ordinary calling convention rather than anything specific to CMake builds. + +## Diagnosing a route + +`prik doctor cmake` reports what a build system would discover -- the imported +package, the metadata answering for it, both entry points, and anything that +could answer instead. Run it through the interpreter in question to see what +that environment offers: + +```bash +PYTHONPATH=. python3 -m prik doctor cmake +``` + +The script's `scikit-build-core` route builds against this checkout with build +isolation off. The isolated build a user gets from `pip wheel .` is covered by +`tests/fortran/infrastructure/building/end_to_end/test_cmake_builds.py`. diff --git a/prik/cli.py b/prik/cli.py index 60a7747a6..0c2f0cd7f 100644 --- a/prik/cli.py +++ b/prik/cli.py @@ -52,6 +52,7 @@ "%(prog)s INPUT [INPUT ...] [BUILD OPTIONS]\n" " %(prog)s {parse,semantics,generate,probe} [OPTIONS] ...\n" " %(prog)s {cmake-dir,install-dir}\n" + " %(prog)s doctor cmake\n" " %(prog)s --version" ) _BUILD_USAGE = ( @@ -89,7 +90,8 @@ " generate Generate contracts or wrapper build files\n" " probe Probe compiler-target datatype and ABI facts\n" " cmake-dir Print the directory holding PRIK's packaged CMake modules\n" - " install-dir Print the prefix holding PRIK's installed data files" + " install-dir Print the prefix holding PRIK's installed data files\n" + " doctor Report how a build system would discover this PRIK" ) _CLI_HELP_EPILOG = ( f"{_HELP_DIVIDER}\n\n" @@ -2964,6 +2966,38 @@ def _run_path_command(args: argparse.Namespace, parser: argparse.ArgumentParser) return 0 +_DOCTOR_HELP_EPILOG = ( + f"{_HELP_DIVIDER}\n\n" + " Report what a CMake build would discover:\n" + " prik doctor cmake\n\n" + " Ask a specific interpreter, the way CMake does:\n" + " /path/to/python -m prik doctor cmake" +) + + +def _doctor_parser(argv: list[str]) -> argparse.ArgumentParser: + parser = _new_cli_parser( + prog="python3 -m prik doctor", + usage="%(prog)s cmake", + description="Report how a build system would discover this PRIK installation.", + epilog=_DOCTOR_HELP_EPILOG, + argv=argv, + ) + parser.set_defaults(command="doctor") + parser.add_argument("topic", choices=("cmake",), help="Diagnostic report to print") + return parser + + +def _run_doctor_command(args: argparse.Namespace, parser: argparse.ArgumentParser) -> int: + """Print the facts that decide which PRIK a CMake build uses.""" + from prik.installation import cmake_discovery_report + + del parser # The only topic is validated by the parser's choices. + for label, value in cmake_discovery_report().items(): + print(f"{label}: {value}") + return 0 + + def _probe_parser(argv: list[str]) -> argparse.ArgumentParser: parser = _new_cli_parser( prog="python3 -m prik probe", @@ -3069,6 +3103,7 @@ def _probe_parser(argv: list[str]) -> argparse.ArgumentParser: "install-dir", "Print the prefix holding PRIK's installed data files.", ), + "doctor": _doctor_parser, } @@ -3178,6 +3213,8 @@ def main(argv: list[str] | None = None) -> int: return _run_probe_command(args, parser) if args.command in {"cmake-dir", "install-dir"}: return _run_path_command(args, parser) + if args.command == "doctor": + return _run_doctor_command(args, parser) args.language = _resolve_language(args.paths, args.language, parser) preprocessing = _build_preprocessing_config(args, parser) print_limit = _validate_main_options(args, parser) diff --git a/prik/cmake_modules/UsePRIK.cmake b/prik/cmake_modules/UsePRIK.cmake index 4e083a2e9..86f1c24f0 100644 --- a/prik/cmake_modules/UsePRIK.cmake +++ b/prik/cmake_modules/UsePRIK.cmake @@ -66,6 +66,31 @@ function(_prik_append_cli_flags command option flags) set(${command} "${_command}" PARENT_SCOPE) endfunction() +function(_prik_report_planning_failure name error) + # A missing PRIK, or a missing PRIK dependency, fails the structural query + # before it reads anything, and says so as an import error. Only that case + # gets the environment hint: a real generation or validation error must + # reach the user as itself, not behind a dependency story. + set(_prik_hint "") + if(error MATCHES "ModuleNotFoundError|ImportError|No module named") + set( + _prik_hint + "\n\nPRIK and its Python dependencies must be importable by the interpreter" + " CMake selected. Check it with:\n" + " \"${Python_EXECUTABLE}\" -m prik --version\n" + "then install PRIK into that environment, or select an interpreter that has" + " it with -DPython_EXECUTABLE=/path/to/python." + ) + string(JOIN "" _prik_hint ${_prik_hint}) + endif() + message( + FATAL_ERROR + "PRIK structural planning failed for ${name}.\n" + "Python_EXECUTABLE: ${Python_EXECUTABLE}\n\n" + "${error}${_prik_hint}" + ) +endfunction() + function(_prik_json_string_list output_variable json) set(_prik_json_path ${ARGN}) string(JSON _prik_item_count ERROR_VARIABLE _prik_json_error LENGTH "${json}" ${_prik_json_path}) @@ -343,7 +368,7 @@ function(prik_add_module name) ERROR_VARIABLE _prik_configure_error ) if(NOT _prik_configure_result EQUAL 0) - message(FATAL_ERROR "PRIK structural planning failed for ${name}:\n${_prik_configure_error}") + _prik_report_planning_failure("${name}" "${_prik_configure_error}") endif() _prik_json_string_list(_prik_generated_sources "${_prik_plan_json}" generated_sources) diff --git a/prik/installation.py b/prik/installation.py index 6921feae2..ec76df66c 100644 --- a/prik/installation.py +++ b/prik/installation.py @@ -7,8 +7,10 @@ from __future__ import annotations +from collections.abc import Callable from importlib import metadata import json +import os from pathlib import Path import site import sys @@ -80,6 +82,89 @@ def _running_distribution() -> metadata.Distribution: raise FileNotFoundError(f"the installed prik distribution does not provide the PRIK running from {_PACKAGE_DIR}") +def cmake_discovery_report() -> dict[str, str]: + """Return the facts that decide which PRIK a CMake build would use. + + Every value is observed, never inferred: which package is imported, which + distribution's metadata answers for it, what the entry points a build + backend reads resolve to, and whether anything else on the path could + answer instead. + """ + from prik import __version__ + from prik.cmake import cmake_module_dir + + report = { + "prik version": __version__, + "imported package": str(_PACKAGE_DIR), + "python executable": sys.executable, + "cmake-dir": str(cmake_module_dir()), + } + report.update(_distribution_facts()) + report["install-dir"] = _reported(install_dir) + for group in ("cmake.root", "cmake.module"): + report[f"entry point {group}"] = _entry_point_facts(group) + conflicts = _discovery_conflicts() + report["conflicts"] = "; ".join(conflicts) if conflicts else "none" + return report + + +def _reported(answer: Callable[[], Path]) -> str: + """Return one reported path, or the reason there is none.""" + try: + return str(answer()) + except FileNotFoundError as exc: + return f"unavailable ({exc})" + + +def _distribution_facts() -> dict[str, str]: + """Return where the metadata answering for ``prik`` lives.""" + try: + distribution = metadata.distribution("prik") + except metadata.PackageNotFoundError: + return {"distribution metadata": "none installed"} + return {"distribution metadata": str(distribution.locate_file(""))} + + +def _entry_point_facts(group: str) -> str: + """Return what one entry-point group resolves to for this installation.""" + from importlib import resources + + try: + entries = [entry for entry in metadata.distribution("prik").entry_points if entry.group == group] + except metadata.PackageNotFoundError: + return "unavailable (prik is not installed)" + if not entries: + return "not declared" + resolved = [] + for entry in entries: + try: + resolved.append(f"{entry.name} -> {resources.files(entry.load())}") + except (ImportError, TypeError) as exc: # pragma: no cover - a broken installation + resolved.append(f"{entry.name} -> unresolvable ({exc})") + return ", ".join(resolved) + + +def _discovery_conflicts() -> list[str]: + """Return anything that could make another PRIK answer instead of this one.""" + conflicts = [] + try: + _running_distribution() + except FileNotFoundError as exc: + conflicts.append(str(exc)) + installed = [ + distribution + for distribution in metadata.distributions() + if (distribution.metadata["Name"] or "").lower() == "prik" + ] + if len(installed) > 1: + locations = ", ".join(sorted(str(distribution.locate_file("")) for distribution in installed)) + conflicts.append(f"{len(installed)} prik distributions are importable: {locations}") + for entry in os.environ.get("PYTHONPATH", "").split(os.pathsep): + if entry and (Path(entry) / "prik" / "__init__.py").is_file() and Path(entry).resolve() != _PACKAGE_DIR.parent: + conflicts.append(f"PYTHONPATH entry holds another prik package: {entry}") + return conflicts + + def _editable_source(distribution: metadata.Distribution) -> Path | None: """Return the tree an editable installation points at, when it is one.""" recorded = distribution.read_text("direct_url.json") diff --git a/pyproject.toml b/pyproject.toml index f6617bb48..e0aaa0176 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -102,6 +102,12 @@ prik = "prik.cli:main" [project.entry-points."cmake.module"] prik = "prik.cmake_modules" +# The entry-point name is the package name scikit-build-core sets _ROOT +# for, so this one must stay PRIK: it is what makes +# find_package(PRIK CONFIG REQUIRED) resolve with no PRIK-specific -D flag. +[project.entry-points."cmake.root"] +PRIK = "prik.cmake_modules" + [project.urls] Homepage = "https://pynumlab.github.io/prik/" Documentation = "https://pynumlab.github.io/prik/" diff --git a/tests/fortran/_support/installed_distribution.py b/tests/fortran/_support/installed_distribution.py index 7c0c4b6a0..4a803a67e 100644 --- a/tests/fortran/_support/installed_distribution.py +++ b/tests/fortran/_support/installed_distribution.py @@ -41,17 +41,21 @@ def clean_environment() -> dict[str, str]: @cache -def installed_prik_python() -> Path: - """Return the interpreter of an environment holding a freshly built wheel.""" +def _workspace() -> Path: + """Return one directory that outlives every test in this session.""" installation = TemporaryDirectory(prefix="prik-installed-wheel-") _INSTALLATIONS.append(installation) - root = Path(installation.name) - distribution_dir = root / "dist" - environment = clean_environment() + return Path(installation.name) + + +@cache +def prik_wheel() -> Path: + """Return a wheel built from the checkout, built once per session.""" + distribution_dir = _workspace() / "dist" wheel_build = subprocess.run( [sys.executable, "-m", "pip", "wheel", "--no-deps", "--wheel-dir", str(distribution_dir), "."], cwd=REPO_ROOT, - env=environment, + env=clean_environment(), capture_output=True, text=True, ) @@ -63,11 +67,19 @@ def installed_prik_python() -> Path: wheels = tuple(distribution_dir.glob("prik-*.whl")) if not wheels: pytest.skip("isolated wheel construction produced no wheel") - environment_dir = root / "installed" + return wheels[0] + + +@cache +def installed_prik_python() -> Path: + """Return the interpreter of an environment holding a freshly built wheel.""" + wheel = prik_wheel() + environment = clean_environment() + environment_dir = _workspace() / "installed" venv.EnvBuilder(with_pip=True, system_site_packages=True).create(environment_dir) installed_python = environment_dir / "bin" / "python" install = subprocess.run( - [str(installed_python), "-m", "pip", "install", "--no-deps", str(wheels[0])], + [str(installed_python), "-m", "pip", "install", "--no-deps", str(wheel)], env=environment, capture_output=True, text=True, 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 35740a6a4..03f92e003 100644 --- a/tests/fortran/infrastructure/building/end_to_end/test_cmake_builds.py +++ b/tests/fortran/infrastructure/building/end_to_end/test_cmake_builds.py @@ -10,11 +10,17 @@ import subprocess import sys import tempfile +import venv import numpy as np import pytest -from tests.fortran._support.installed_distribution import clean_environment, installed_prik_python +from tests.fortran._support.installed_distribution import ( + UNAVAILABLE_MARKERS, + clean_environment, + installed_prik_python, + prik_wheel, +) REPOSITORY_ROOT = Path(__file__).resolve().parents[5] @@ -1899,3 +1905,124 @@ def test_installed_wheel_discovers_and_builds_with_use_prik(tmp_path: Path): environment=installed_environment, ) assert imported.returncode == 0 + + +EXAMPLE_PROJECT = REPOSITORY_ROOT / "examples" / "cmake" + + +@pytest.mark.fortran_end_to_end +@pytest.mark.slow +@pytest.mark.skipif( + shutil.which("cmake") is None or shutil.which("gfortran") is None or shutil.which("gcc") is None, + reason="CMake, gfortran, and gcc are required", +) +def test_isolated_scikit_build_core_wheel_finds_prik_without_any_argument(tmp_path: Path): + """The end-user route: pip builds in its own environment and no -D flag names PRIK. + + Build isolation is what makes this the real experience -- the build + environment holds only what ``[build-system] requires`` installs, so + ``find_package(PRIK CONFIG REQUIRED)`` has to resolve from PRIK's own + ``cmake.root`` entry point. The requirement is redirected to the wheel + built from this checkout, which is the only difference from a user's + ``pip wheel .``. + """ + wheel = prik_wheel() + project = tmp_path / "isolated example" + shutil.copytree(EXAMPLE_PROJECT, project) + manifest = project / "pyproject.toml" + manifest.write_text( + manifest.read_text(encoding="utf-8").replace('"prik"', f'"prik @ {wheel.as_uri()}"'), + encoding="utf-8", + ) + environment_dir = tmp_path / "user environment" + venv.EnvBuilder(with_pip=True).create(environment_dir) + user_python = environment_dir / "bin" / "python" + + built = subprocess.run( + [str(user_python), "-m", "pip", "wheel", "--no-deps", "--wheel-dir", str(tmp_path / "dist"), str(project)], + env=clean_environment(), + capture_output=True, + text=True, + ) + if built.returncode != 0: + output = built.stderr.strip() or built.stdout.strip() + if any(marker.lower() in output.lower() for marker in UNAVAILABLE_MARKERS): + pytest.skip(f"an isolated build environment is unavailable: {output}") + pytest.fail(f"isolated scikit-build-core build failed:\n{output}") + wheels = tuple((tmp_path / "dist").glob("prik_cmake_example-*.whl")) + assert wheels, "the isolated build produced no example wheel" + + _run( + [str(user_python), "-m", "pip", "install", str(wheels[0]), "numpy"], + environment=clean_environment(), + ) + called = _run( + [ + str(user_python), + "-c", + "import numpy, heat; print(heat.kernel.diffuse(numpy.array([0.0, 1.0, 0.0]), numpy.float64(0.25)))", + ], + environment=clean_environment(), + ) + assert called.stdout.split() == ["[0.", "0.5", "0.", "]"], called.stdout + + +@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_structural_planning_failure_names_the_interpreter_that_cannot_import_prik(tmp_path: Path): + """A configure-time import failure is an environment problem, so it says which one.""" + project = tmp_path / "unusable interpreter" + project.mkdir() + (project / "square.f90").write_text( + "real(8) function square(x) result(y)\n real(8), intent(in) :: x\n y = x * x\nend function square\n", + encoding="utf-8", + ) + _write_project(project, "prik_add_module(square FORTRAN_SOURCES square.f90)\n") + environment_dir = tmp_path / "environment without prik" + venv.EnvBuilder(with_pip=False).create(environment_dir) + bare_python = environment_dir / "bin" / "python" + + result = subprocess.run( + ["cmake", "-S", str(project), "-B", str(project / "build"), f"-DPython_EXECUTABLE={bare_python}"], + env=clean_environment(), + capture_output=True, + text=True, + ) + output = result.stdout + result.stderr + + assert result.returncode != 0 + assert str(bare_python) in output + assert "No module named prik" in output + assert "must be importable by the interpreter" in output + + +@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_structural_planning_failure_keeps_a_real_error_unexplained(tmp_path: Path): + """A generation error must reach the user as itself, not as a dependency story.""" + project = tmp_path / "rejected option" + project.mkdir() + (project / "square.f90").write_text( + "real(8) function square(x) result(y)\n real(8), intent(in) :: x\n y = x * x\nend function square\n", + encoding="utf-8", + ) + _write_project( + project, + "prik_add_module(square FORTRAN_SOURCES square.f90 PRIK_ARGS --definitely-not-an-option)\n", + ) + + result = subprocess.run( + ["cmake", "-S", str(project), "-B", str(project / "build")], + env=_environment(), + capture_output=True, + text=True, + ) + output = result.stdout + result.stderr + + assert result.returncode != 0 + assert "unrecognized arguments" in output + assert "must be importable by the interpreter" not in output diff --git a/tests/fortran/infrastructure/building/pipeline/test_cmake_packaging.py b/tests/fortran/infrastructure/building/pipeline/test_cmake_packaging.py index adef3e7ca..048cf5941 100644 --- a/tests/fortran/infrastructure/building/pipeline/test_cmake_packaging.py +++ b/tests/fortran/infrastructure/building/pipeline/test_cmake_packaging.py @@ -56,6 +56,31 @@ def test_installed_distribution_exposes_the_cmake_module_entry_point() -> None: assert (entry_point_dir / "UsePRIK.cmake").is_file() +@pytest.mark.slow +def test_installed_distribution_exposes_the_cmake_root_entry_point_as_prik() -> None: + """scikit-build-core sets ``_ROOT``, so the name is the contract. + + ``find_package(PRIK CONFIG REQUIRED)`` resolves with no argument only + because that variable comes out as ``PRIK_ROOT``, which makes the entry + point's name load-bearing rather than decorative. + """ + name, directory = installed_output( + "import os\n" + "from importlib import metadata, resources\n" + "roots = [\n" + " entry\n" + " for entry in metadata.distribution('prik').entry_points\n" + " if entry.group == 'cmake.root'\n" + "]\n" + "assert len(roots) == 1, roots\n" + "print(roots[0].name)\n" + "print(os.path.realpath(str(resources.files(roots[0].load()))))\n" + ).splitlines() + + assert name == "PRIK" + assert (Path(directory) / "PRIKConfig.cmake").is_file() + + @pytest.mark.slow def test_installed_console_script_prints_the_paths_a_build_configures_with() -> None: """``prik cmake-dir`` and ``prik install-dir`` answer for the installation they run from.""" diff --git a/tests/fortran/infrastructure/cli/pipeline/test_stage_dispatch.py b/tests/fortran/infrastructure/cli/pipeline/test_stage_dispatch.py index 04d0ef672..be2e7e550 100644 --- a/tests/fortran/infrastructure/cli/pipeline/test_stage_dispatch.py +++ b/tests/fortran/infrastructure/cli/pipeline/test_stage_dispatch.py @@ -845,3 +845,22 @@ def test_install_dir_prints_a_prefix_that_holds_installed_data_or_reports_none() else: assert "prik" in printed.stderr.lower() assert not printed.stdout.strip() + + +def test_doctor_cmake_reports_the_discovery_facts_a_build_would_use(): + """The report is observed facts, so a confusing environment shows up in it.""" + printed = subprocess.run( + [sys.executable, "-m", "prik", "doctor", "cmake"], + capture_output=True, + text=True, + check=True, + ) + report = dict(line.split(": ", 1) for line in printed.stdout.splitlines()) + + assert report["cmake-dir"] == str(cmake_module_dir()) + assert report["imported package"] == str(cmake_module_dir().parent) + assert report["python executable"] == sys.executable + for label in ("prik version", "distribution metadata", "install-dir", "conflicts"): + assert report[label] + for group in ("cmake.root", "cmake.module"): + assert report[f"entry point {group}"] From c78c621c7989164a5bd84753e1447e74229a1636 Mon Sep 17 00:00:00 2001 From: said Date: Sun, 13 Sep 2026 13:45:21 +0100 Subject: [PATCH 2/4] codex: require scikit-build-core 0.11 for the cmake.root route The example and the guide asked for scikit-build-core>=0.10, but cmake.root does not exist there: the entry-point group, its prefix_roots plumbing, and the _ROOT cache entry all arrive in 0.11. A project pinned to 0.10.x would therefore configure without PRIK_ROOT and fail the documented find_package(PRIK CONFIG REQUIRED) route with no PRIK-specific -D flag. Both declarations now require scikit-build-core>=0.11, and the guide says why that is the floor. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KMz3kPhNruZUmNkDAMT9KJ --- CHANGELOG.md | 3 ++- docs/user/guide/cmake.md | 5 +++-- examples/cmake/pyproject.toml | 2 +- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ed7a495a1..52ffb951c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -77,7 +77,8 @@ release tags add a leading `v` to the package version. that runs each route and calls the built extension. - PRIK also publishes scikit-build-core's `cmake.root` entry point, which sets - `PRIK_ROOT`, so a project listing PRIK in `[build-system] requires` resolves + `PRIK_ROOT`, so a project listing PRIK and `scikit-build-core>=0.11` in + `[build-system] requires` resolves `find_package(PRIK CONFIG REQUIRED)` with no `PRIK_DIR`, `CMAKE_PREFIX_PATH`, or `CMAKE_MODULE_PATH`. `include(UsePRIK)` keeps working through the existing `cmake.module` entry point. diff --git a/docs/user/guide/cmake.md b/docs/user/guide/cmake.md index 71772b9ce..a8d8f0c72 100644 --- a/docs/user/guide/cmake.md +++ b/docs/user/guide/cmake.md @@ -97,14 +97,15 @@ name PRIK as a build requirement: ```toml [build-system] -requires = ["scikit-build-core>=0.10", "prik"] +requires = ["scikit-build-core>=0.11", "prik"] build-backend = "scikit_build_core.build" ``` The backend installs PRIK into its own build environment and reads PRIK's entry points from there, so the project keeps the same `find_package(PRIK CONFIG REQUIRED)` it uses everywhere else, and building the -wheel takes no PRIK-specific argument: +wheel takes no PRIK-specific argument. `cmake.root` arrived in +scikit-build-core 0.11, which is why that is the floor: ```bash python3 -m pip wheel . diff --git a/examples/cmake/pyproject.toml b/examples/cmake/pyproject.toml index 726041e91..be3123106 100644 --- a/examples/cmake/pyproject.toml +++ b/examples/cmake/pyproject.toml @@ -1,5 +1,5 @@ [build-system] -requires = ["scikit-build-core>=0.10", "prik"] +requires = ["scikit-build-core>=0.11", "prik"] build-backend = "scikit_build_core.build" [project] From d015ea4fe537d49c3845c6bfe9d5e3ce96fb694b Mon Sep 17 00:00:00 2001 From: said Date: Sun, 13 Sep 2026 14:01:06 +0100 Subject: [PATCH 3/4] codex: group the CMake discovery routes by who needs them The discovery section had grown into nine consecutive blocks, so the common path disappeared after the route table. The routes now split by the reader: "Configuring a project yourself" keeps PRIK_DIR against CMAKE_PREFIX_PATH and the install-dir record, while "Packaging and pinned interpreters" keeps scikit-build-core, the entry-point table, and asking a selected interpreter. Diagnosis gets its own heading rather than sitting under packaging, because a build that finds no PRIK, or the wrong one, is what sends a reader looking for prik doctor cmake from any route. The runnable example moves up beside the table it demonstrates. No fact changed. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KMz3kPhNruZUmNkDAMT9KJ --- docs/user/guide/cmake.md | 49 +++++++++++++++++++++------------------- 1 file changed, 26 insertions(+), 23 deletions(-) diff --git a/docs/user/guide/cmake.md b/docs/user/guide/cmake.md index a8d8f0c72..12815ffa8 100644 --- a/docs/user/guide/cmake.md +++ b/docs/user/guide/cmake.md @@ -72,18 +72,17 @@ helper, and these routes differ only in how CMake reaches it: | Module path | `include(UsePRIK)` | `-DCMAKE_MODULE_PATH="$(prik cmake-dir)"` | | scikit-build-core | `find_package(PRIK CONFIG REQUIRED)` | nothing at all | +[`examples/cmake/`](../../../examples/cmake/README.md) is a runnable project +that builds the same module through every one of them, with a script that +checks each route in turn. + +### Configuring a project yourself + +The three command-line routes use whichever `prik` the shell resolves. `PRIK_DIR` is package-specific, so setting it does not affect how other CMake packages are found; `CMAKE_PREFIX_PATH` is the broader search path every `find_package()` call shares. -PRIK publishes both of scikit-build-core's discovery entry points, so either -project form works there with nothing on the command line: - -| Entry point | What the backend sets | What the project calls | -| --- | --- | --- | -| `cmake.root` | `PRIK_ROOT` | `find_package(PRIK CONFIG REQUIRED)` | -| `cmake.module` | `CMAKE_MODULE_PATH` | `include(UsePRIK)` | - `prik install-dir` prints the prefix this PRIK's own installation wrote its data files under, which carries the same modules in `share/prik/cmake` and also resolves everything else installed there. The prefix comes from that @@ -92,6 +91,8 @@ for it. A source checkout installs nothing, and an editable install writes no data files, so `install-dir` reports that instead of naming a prefix; `cmake-dir` always answers. +### Packaging and pinned interpreters + For a [scikit-build-core](https://scikit-build-core.readthedocs.io/) wheel, name PRIK as a build requirement: @@ -101,22 +102,27 @@ requires = ["scikit-build-core>=0.11", "prik"] build-backend = "scikit_build_core.build" ``` -The backend installs PRIK into its own build environment and reads PRIK's -entry points from there, so the project keeps the same -`find_package(PRIK CONFIG REQUIRED)` it uses everywhere else, and building the -wheel takes no PRIK-specific argument. `cmake.root` arrived in +The backend installs PRIK into its own build environment and reads PRIK's entry +points from there -- not the `prik` the shell resolves -- so the project keeps +the same `find_package(PRIK CONFIG REQUIRED)` it uses everywhere else, and +building the wheel takes no PRIK-specific argument. `cmake.root` arrived in scikit-build-core 0.11, which is why that is the floor: ```bash python3 -m pip wheel . ``` -The three command-line routes above use whichever `prik` the shell resolves. -scikit-build-core instead uses the PRIK installed in its build environment, -which it finds through those entry points. When the build must -match the interpreter CMake itself selected -- several environments on one -machine, or a `Python_EXECUTABLE` the project pins -- ask that interpreter, -which also needs no `-D` argument: +PRIK publishes both of scikit-build-core's discovery entry points, so either +project form works there with nothing on the command line: + +| Entry point | What the backend sets | What the project calls | +| --- | --- | --- | +| `cmake.root` | `PRIK_ROOT` | `find_package(PRIK CONFIG REQUIRED)` | +| `cmake.module` | `CMAKE_MODULE_PATH` | `include(UsePRIK)` | + +When the build must match the interpreter CMake itself selected -- several +environments on one machine, or a `Python_EXECUTABLE` the project pins -- ask +that interpreter, which also needs no `-D` argument: ```cmake execute_process( @@ -137,7 +143,8 @@ include(UsePRIK) it generates, which is why that project configures with a plain `cmake -S . -B build`. -When a build cannot find PRIK, or finds one you did not expect, +### When a build finds no PRIK, or the wrong one + `prik doctor cmake` reports what a build system would discover: the imported package, the distribution metadata answering for it, `cmake-dir`, `install-dir`, both entry points, and any duplicate installation or @@ -145,10 +152,6 @@ package, the distribution metadata answering for it, `cmake-dir`, question -- `"${Python_EXECUTABLE}" -m prik doctor cmake` -- to see what CMake sees. -[`examples/cmake/`](../../../examples/cmake/README.md) is a runnable project -that builds the same module through every route, with a script that checks each -one in turn. - ## Common `prik_add_module()` options | Keyword | Purpose | From d988f4852d470523c0ea22a6ab5ad5f891704073 Mon Sep 17 00:00:00 2001 From: said Date: Sun, 13 Sep 2026 14:09:05 +0100 Subject: [PATCH 4/4] codex: pair the compilers in the isolated scikit-build-core test The macOS lane failed the new test: scikit-build-core picked the default C compiler, Apple Clang, beside the job's Homebrew GNU Fortran, and the generated binding's #include is only found beside the Fortran runtime that ships it. PRIK pairs the C driver with the Fortran vendor for that reason, which is why every other CMake test here names CMAKE_C_COMPILER. The test now passes the same pair through CMAKE_ARGS, the variable a build backend honours, and asserts no argument it sets names PRIK -- the claim under test is that PRIK needs no flag, not that CMake needs none. The guide states the constraint too, since a macOS reader with Homebrew GNU Fortran meets it before we do. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KMz3kPhNruZUmNkDAMT9KJ --- docs/user/guide/cmake.md | 8 ++++++++ .../building/end_to_end/test_cmake_builds.py | 13 ++++++++++++- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/docs/user/guide/cmake.md b/docs/user/guide/cmake.md index 12815ffa8..2186e5034 100644 --- a/docs/user/guide/cmake.md +++ b/docs/user/guide/cmake.md @@ -216,6 +216,14 @@ language must be enabled because every PRIK extension contains generated C binding code, and Fortran must be enabled whenever the module contributes Fortran sources. +Select the two compilers from one vendor. A generated binding can include the +Fortran runtime's `ISO_Fortran_binding.h`, which a C compiler from another +vendor does not find: Apple Clang beside a Homebrew GNU Fortran fails to +compile the binding, while GNU `gcc` beside GNU Fortran resolves it. The +toolchain stays CMake's to choose, so name the pair through the usual +`CMAKE_C_COMPILER` and `CMAKE_Fortran_COMPILER`, or `CMAKE_ARGS` when a build +backend drives the configure step. + 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/tests/fortran/infrastructure/building/end_to_end/test_cmake_builds.py b/tests/fortran/infrastructure/building/end_to_end/test_cmake_builds.py index 03f92e003..cddaedf81 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 @@ -1925,6 +1925,12 @@ def test_isolated_scikit_build_core_wheel_finds_prik_without_any_argument(tmp_pa ``cmake.root`` entry point. The requirement is redirected to the wheel built from this checkout, which is the only difference from a user's ``pip wheel .``. + + ``CMAKE_ARGS`` names the compilers the way every other CMake test here + does, because PRIK pairs the C driver with the Fortran vendor: a generated + binding can include the Fortran runtime's ``ISO_Fortran_binding.h``, which + Apple Clang does not find beside a Homebrew GNU Fortran. That is ordinary + toolchain configuration, and no argument here names PRIK. """ wheel = prik_wheel() project = tmp_path / "isolated example" @@ -1937,10 +1943,15 @@ def test_isolated_scikit_build_core_wheel_finds_prik_without_any_argument(tmp_pa environment_dir = tmp_path / "user environment" venv.EnvBuilder(with_pip=True).create(environment_dir) user_python = environment_dir / "bin" / "python" + build_environment = clean_environment() + build_environment["CMAKE_ARGS"] = ( + f"-DCMAKE_C_COMPILER={shutil.which('gcc')} -DCMAKE_Fortran_COMPILER={shutil.which('gfortran')}" + ) + assert "PRIK" not in build_environment["CMAKE_ARGS"] built = subprocess.run( [str(user_python), "-m", "pip", "wheel", "--no-deps", "--wheel-dir", str(tmp_path / "dist"), str(project)], - env=clean_environment(), + env=build_environment, capture_output=True, text=True, )