diff --git a/CHANGELOG.md b/CHANGELOG.md index 18b51ad4c..52ffb951c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -72,10 +72,27 @@ 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 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. + +- 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..2186e5034 100644 --- a/docs/user/guide/cmake.md +++ b/docs/user/guide/cmake.md @@ -70,8 +70,15 @@ 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 | +[`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. @@ -84,29 +91,38 @@ 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: ```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 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 -- 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 . --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 -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( @@ -127,9 +143,14 @@ include(UsePRIK) it generates, which is why that project configures with a plain `cmake -S . -B build`. -[`examples/cmake/`](../../../examples/cmake/README.md) is a runnable project -that builds the same module through every route, with a script that checks each -one in turn. +### 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 +`PYTHONPATH` entry that could answer instead. Run it through the interpreter in +question -- `"${Python_EXECUTABLE}" -m prik doctor cmake` -- to see what CMake +sees. ## Common `prik_add_module()` options @@ -195,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/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/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] 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..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 @@ -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,135 @@ 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 .``. + + ``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" + 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" + 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=build_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}"]