diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index ad2a194..f9100b0 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -37,6 +37,8 @@ jobs: environment: pypi permissions: id-token: write + attestations: write + contents: read runs-on: ubuntu-latest if: github.event_name == 'release' && github.event.action == 'published' @@ -46,8 +48,9 @@ jobs: name: Packages path: dist - - uses: pypa/gh-action-pypi-publish@release/v1 + - name: Generate artifact attestation for sdist and wheel + uses: actions/attest-build-provenance@173725a1209d09b31f9d30a3890cf2757ebbff0d # v1.1.2 with: - # Remember to tell (test-)pypi about this repo before publishing - # Remove this line to publish to PyPI - repository-url: https://test.pypi.org/legacy/ + subject-path: "dist/f2py*" + + - uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8fc083f..8f98dc6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,7 +41,7 @@ jobs: fail-fast: false matrix: python-version: ["3.8", "3.12"] - runs-on: [ubuntu-latest, windows-latest, macos-14] + runs-on: [ubuntu-latest, macos-14] include: - python-version: "pypy-3.10" @@ -57,15 +57,22 @@ jobs: python-version: ${{ matrix.python-version }} allow-prereleases: true + - name: Install gfortran + uses: fortran-lang/setup-fortran@v1 + if: runner.os != 'Linux' + with: + compiler: gcc + version: 13 + + - name: Use GFortran + if: runner.os == 'macOS' + run: | + echo "CC=clang" >> $GITHUB_ENV + echo "FC=gfortran" >> $GITHUB_ENV + - name: Install package run: python -m pip install .[test] - name: Test package run: >- - python -m pytest -ra --cov --cov-report=xml --cov-report=term - --durations=20 - - - name: Upload coverage report - uses: codecov/codecov-action@v4.4.1 - with: - token: ${{ secrets.CODECOV_TOKEN }} + python -m pytest --durations=20 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 5c9cbd1..686a833 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -33,27 +33,28 @@ repos: - id: rst-inline-touching-normal - repo: https://github.com/pre-commit/mirrors-prettier - rev: "v3.1.0" + rev: "v4.0.0-alpha.8" hooks: - id: prettier types_or: [yaml, markdown, html, css, scss, javascript, json] args: [--prose-wrap=always] - repo: https://github.com/astral-sh/ruff-pre-commit - rev: "v0.4.8" + rev: "v0.5.0" hooks: - id: ruff args: ["--fix", "--show-fixes"] - id: ruff-format - repo: https://github.com/pre-commit/mirrors-mypy - rev: "v1.10.0" + rev: "v1.10.1" hooks: - id: mypy files: src|tests args: [] additional_dependencies: - pytest + - scikit-build-core - repo: https://github.com/codespell-project/codespell rev: "v2.3.0" @@ -80,7 +81,7 @@ repos: additional_dependencies: ["validate-pyproject-schema-store[all]"] - repo: https://github.com/python-jsonschema/check-jsonschema - rev: "0.28.4" + rev: "0.28.6" hooks: - id: check-dependabot - id: check-github-workflows diff --git a/README.md b/README.md index 3d9e585..d725b35 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,75 @@ # f2py-cmake [![Actions Status][actions-badge]][actions-link] + + [![PyPI version][pypi-version]][pypi-link] -[![Conda-Forge][conda-badge]][conda-link] [![PyPI platforms][pypi-platforms]][pypi-link] + +This provides helpers for using F2Py. Use: + +```cmake +include(UseF2Py) +``` + +You must have found a Python interpreter beforehand. This will define a +`F2Py::F2Py` target (along with a matching `F2PY_EXECUTABLE` variable). It will +also provide the following helper functions: + +```cmake +f2py_object_library( ) + +f2py_generate_module( ... + [F2PY_ARGS ...] + [F77 | F90] + [NOLOWER] + [OUTPUT_DIR ] + [OUTPUT_VARIABLE ] + ) +``` + +## Example + +```cmake +find_package( + Python + COMPONENTS Interpreter Development.Module NumPy + REQUIRED) + +include(UseF2Py) + +# Create the F2Py `numpyobject` library. +f2py_object_library(f2py_object OBJECT) + +f2py_generate_module(fibby fib1.f OUTPUT_VARIABLE fibby_files) + +python_add_library(fibby MODULE "${fibby_files}" WITH_SOABI) +target_link_library(fibby PRIVATE f2py_object) +``` + +## scikit-build-core + +To use this package with scikit-build-core, you need to include it in your build +requirements: + +```toml +[build-system] +requires = ["scikit-build-core", "numpy", "f2py-cmake"] +build-backend = "scikit_build_core.build" +``` + [actions-badge]: https://github.com/scikit-build/f2py-cmake/workflows/CI/badge.svg [actions-link]: https://github.com/scikit-build/f2py-cmake/actions -[conda-badge]: https://img.shields.io/conda/vn/conda-forge/f2py-cmake -[conda-link]: https://github.com/conda-forge/f2py-cmake-feedstock [github-discussions-badge]: https://img.shields.io/static/v1?label=Discussions&message=Ask&color=blue&logo=github [github-discussions-link]: https://github.com/scikit-build/f2py-cmake/discussions [pypi-link]: https://pypi.org/project/f2py-cmake/ diff --git a/noxfile.py b/noxfile.py index 93945eb..4cd91f3 100644 --- a/noxfile.py +++ b/noxfile.py @@ -1,7 +1,6 @@ from __future__ import annotations import argparse -import shutil from pathlib import Path import nox @@ -9,8 +8,8 @@ DIR = Path(__file__).parent.resolve() nox.needs_version = ">=2024.3.2" -nox.options.sessions = ["lint", "pylint", "tests"] nox.options.default_venv_backend = "uv|virtualenv" +nox.options.sessions = ["lint", "pylint", "tests"] @nox.session @@ -31,7 +30,7 @@ def pylint(session: nox.Session) -> None: """ # This needs to be installed into the package environment, and is slower # than a pre-commit check - session.install(".", "pylint>=3.2") + session.install(".", "pylint") session.run("pylint", "f2py_cmake", *session.posargs) @@ -40,37 +39,48 @@ def tests(session: nox.Session) -> None: """ Run the unit and regular tests. """ - session.install(".[test]") + session.install("-e.[test]") session.run("pytest", *session.posargs) @nox.session(reuse_venv=True) def docs(session: nox.Session) -> None: """ - Build the docs. Pass --non-interactive to avoid serving. First positional argument is the target directory. + Build the docs. Pass "--serve" to serve. Pass "-b linkcheck" to check links. """ parser = argparse.ArgumentParser() + parser.add_argument("--serve", action="store_true", help="Serve after building") parser.add_argument( "-b", dest="builder", default="html", help="Build target (default: html)" ) - parser.add_argument("output", nargs="?", help="Output directory") args, posargs = parser.parse_known_args(session.posargs) - serve = args.builder == "html" and session.interactive - session.install("-e.[docs]", "sphinx-autobuild") + if args.builder != "html" and args.serve: + session.error("Must not specify non-HTML builder with --serve") + + extra_installs = ["sphinx-autobuild"] if args.serve else [] + + session.install("-e.[docs]", *extra_installs) + session.chdir("docs") + + if args.builder == "linkcheck": + session.run( + "sphinx-build", "-b", "linkcheck", ".", "_build/linkcheck", *posargs + ) + return shared_args = ( "-n", # nitpicky mode "-T", # full tracebacks f"-b={args.builder}", - "docs", - args.output or f"docs/_build/{args.builder}", + ".", + f"_build/{args.builder}", *posargs, ) - if serve: - session.run("sphinx-autobuild", "--open-browser", *shared_args) + if args.serve: + session.run("sphinx-autobuild", *shared_args) else: session.run("sphinx-build", "--keep-going", *shared_args) @@ -82,14 +92,15 @@ def build_api_docs(session: nox.Session) -> None: """ session.install("sphinx") + session.chdir("docs") session.run( "sphinx-apidoc", "-o", - "docs/api/", + "api/", "--module-first", "--no-toc", "--force", - "src/f2py_cmake", + "../src/f2py_cmake", ) @@ -99,9 +110,5 @@ def build(session: nox.Session) -> None: Build an SDist and wheel. """ - build_path = DIR.joinpath("build") - if build_path.exists(): - shutil.rmtree(build_path) - session.install("build") session.run("python", "-m", "build") diff --git a/pyproject.toml b/pyproject.toml index ee8218d..90ddf5c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ name = "f2py-cmake" authors = [ { name = "Henry Schreiner", email = "henryfs@princeton.edu" }, ] -description = "Helper for F2Py and CMake" +description = "CMake helpers for building F2Py modules" readme = "README.md" license.file = "LICENSE" requires-python = ">=3.8" @@ -32,14 +32,14 @@ classifiers = [ dynamic = ["version"] dependencies = [] +[project.entry-points."cmake.module"] +any = "f2py_cmake.cmake" + [project.optional-dependencies] test = [ "pytest >=6", - "pytest-cov >=3", -] -dev = [ - "pytest >=6", - "pytest-cov >=3", + "scikit-build-core", + "numpy", ] docs = [ "sphinx>=7.0", @@ -61,9 +61,11 @@ version.source = "vcs" build.hooks.vcs.version-file = "src/f2py_cmake/_version.py" [tool.hatch.envs.default] -features = ["test"] -scripts.test = "pytest {args}" +installer = "uv" +[tool.hatch.envs.hatch-test] +features = ["test"] +extra-dependencies = ["cmake", "ninja"] [tool.pytest.ini_options] minversion = "6.0" @@ -152,6 +154,5 @@ messages_control.disable = [ "fixme", "line-too-long", "missing-module-docstring", - "missing-function-docstring", "wrong-import-position", ] diff --git a/src/f2py_cmake/__init__.py b/src/f2py_cmake/__init__.py index 9c8f03f..57ca86b 100644 --- a/src/f2py_cmake/__init__.py +++ b/src/f2py_cmake/__init__.py @@ -1,7 +1,7 @@ """ Copyright (c) 2024 Henry Schreiner. All rights reserved. -f2py-cmake: Helper for F2Py and CMake +f2py-cmake: CMake helpers for building F2Py modules """ from __future__ import annotations diff --git a/src/f2py_cmake/cmake/UseF2Py.cmake b/src/f2py_cmake/cmake/UseF2Py.cmake new file mode 100644 index 0000000..9a4d816 --- /dev/null +++ b/src/f2py_cmake/cmake/UseF2Py.cmake @@ -0,0 +1,125 @@ +if(CMAKE_VERSION VERSION_LESS 3.17) + message(FATAL_ERROR "CMake 3.17+ required") +endif() + +include_guard(GLOBAL) + +if(TARGET Python::NumPy) + set(_Python Python) +elseif(TARGET Python3::NumPy) + set(_Python Python3) +else() + message(FATAL_ERROR "You must find Python or Python3 with the NumPy component before including F2PY!") +endif() + +execute_process( + COMMAND "${${_Python}_EXECUTABLE}" -c + "import numpy.f2py; print(numpy.f2py.get_include())" + OUTPUT_VARIABLE F2PY_inc_output + ERROR_VARIABLE F2PY_inc_error + RESULT_VARIABLE F2PY_inc_result + OUTPUT_STRIP_TRAILING_WHITESPACE + ERROR_STRIP_TRAILING_WHITESPACE) + +if(NOT F2PY_inc_result EQUAL 0) + message(FATAL_ERROR "Can't find f2py, got ${F2PY_inc_output} ${F2PY_inc_error}") +endif() + +set(F2PY_INCLUDE_DIR "${F2PY_inc_output}" CACHE STRING "" FORCE) +set(F2PY_OBJECT_FILES "${F2PY_inc_output}/fortranobject.c;${F2PY_inc_output}/fortranobject.h" CACHE STRING "" FORCE) +mark_as_advanced(F2PY_INCLUDE_DIR F2PY_OBJECT_FILES) + +add_library(F2Py::Headers IMPORTED INTERFACE) +target_include_directories(F2Py::Headers INTERFACE "${F2PY_INCLUDE_DIR}") + +function(f2py_object_library NAME TYPE) + add_library(${NAME} ${TYPE} "${F2PY_INCLUDE_DIR}/fortranobject.c") + target_link_libraries(${NAME} PUBLIC ${_Python}::NumPy F2Py::Headers) + if("${TYPE}" STREQUAL "OBJECT") + set_property(TARGET ${NAME} PROPERTY POSITION_INDEPENDENT_CODE ON) + endif() +endfunction() + +function(f2py_generate_module NAME) + cmake_parse_arguments( + PARSE_ARGV 1 + F2PY + "NOLOWER;F77;F90" + "OUTPUT_DIR;OUTPUT_VARIABLE" + "F2PY_ARGS" + ) + set(ALL_FILES ${F2PY_UNPARSED_ARGUMENTS}) + + if(NOT ALL_FILES) + message(FATAL_ERROR "One or more input files must be specified") + endif() + + if(NOT F2PY_OUTPUT_DIR) + set(F2PY_OUTPUT_DIR "${CMAKE_CURRENT_BINARY_DIR}") + endif() + + if(NAME MATCHES "\\.pyf$") + set(_file_arg "${NAME}") + get_filename_component(NAME "${NAME}" NAME_WE) + else() + set(_file_arg -m ${NAME}) + endif() + + if(F2PY_F77 AND F2PY_F90) + message(FATAL_ERROR "Can't specify F77 and F90") + elseif(NOT F2PY_F77 AND NOT F2PY_F90) + set(HAS_F90_FILE FALSE) + + foreach(file IN LISTS ALL_FILES) + if("${file}" MATCHES "\\.f90$") + set(HAS_F90_FILE TRUE) + break() + endif() + endforeach() + + if(HAS_F90_FILE) + set(F2PY_F90 ON) + else() + set(F2PY_F77 ON) + endif() + endif() + + if(F2PY_F77) + set(wrapper_files ${NAME}-f2pywrappers.f) + else() + set(wrapper_files ${NAME}-f2pywrappers.f ${NAME}-f2pywrappers2.f90) + endif() + + if(F2PY_NOLOWER) + set(lower "--no-lower") + else() + set(lower "--lower") + endif() + + set(abs_all_files) + foreach(file IN LISTS ALL_FILES) + if(IS_ABSOLUTE "${file}") + list(APPEND abs_all_files "${file}") + else() + list(APPEND abs_all_files "${CMAKE_CURRENT_SOURCE_DIR}/${file}") + endif() + endforeach() + + add_custom_command( + OUTPUT ${NAME}module.c ${wrapper_files} + DEPENDS ${ALL_FILES} + VERBATIM + COMMAND + "${${_Python}_EXECUTABLE}" -m numpy.f2py + "${abs_all_files}" ${_file_arg} ${lower} ${F2PY_F2PY_ARGS} + COMMAND + "${CMAKE_COMMAND}" -E touch ${wrapper_files} + WORKING_DIRECTORY "${F2PY_OUTPUT_DIR}" + COMMENT + "F2PY making ${NAME} wrappers" + ) + + if(F2PY_OUTPUT_VARIABLE) + set(${F2PY_OUTPUT_VARIABLE} ${NAME}module.c ${wrapper_files} PARENT_SCOPE) + endif() +endfunction() diff --git a/src/f2py_cmake/cmake/__init__.py b/src/f2py_cmake/cmake/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..50e0aeb --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +import importlib.metadata +import shutil +import subprocess + + +def _get_program(name: str) -> str: + res = shutil.which(name) + if res is None: + return f"No {name} executable found on PATH" + result = subprocess.run( + [res, "--version"], check=True, text=True, capture_output=True + ) + version = result.stdout.splitlines()[0] + return f"{res}: {version}" + + +def pytest_report_header() -> str: + interesting_packages = { + "cmake", + "ninja", + "packaging", + "pip", + "scikit-build-core", + } + valid = [] + for package in interesting_packages: + try: + version = importlib.metadata.version(package) + except ModuleNotFoundError: + continue + valid.append(f"{package}=={version}") + reqs = " ".join(sorted(valid)) + pkg_line = f"installed packages of interest: {reqs}" + prog_lines = [_get_program(n) for n in ("cmake3", "cmake", "ninja")] + + return "\n".join([pkg_line, *prog_lines]) diff --git a/tests/packages/f77/CMakeLists.txt b/tests/packages/f77/CMakeLists.txt new file mode 100644 index 0000000..9260499 --- /dev/null +++ b/tests/packages/f77/CMakeLists.txt @@ -0,0 +1,18 @@ +cmake_minimum_required(VERSION 3.15...3.29) +project(${SKBUILD_PROJECT_NAME} LANGUAGES C Fortran) + +find_package( + Python + COMPONENTS Interpreter Development.Module NumPy + REQUIRED) + +include(UseF2Py) + +f2py_object_library(f2py_object OBJECT) + +f2py_generate_module(fibby fib1.f OUTPUT_VARIABLE fibby_files) + +python_add_library(fibby MODULE "${fibby_files}" WITH_SOABI) +target_link_libraries(fibby PRIVATE f2py_object) + +install(TARGETS fibby DESTINATION .) diff --git a/tests/packages/f77/fib1.f b/tests/packages/f77/fib1.f new file mode 100644 index 0000000..74ea445 --- /dev/null +++ b/tests/packages/f77/fib1.f @@ -0,0 +1,18 @@ +C FILE: FIB1.F + SUBROUTINE FIB(A,N) +C +C CALCULATE FIRST N FIBONACCI NUMBERS +C + INTEGER N + REAL*8 A(N) + DO I=1,N + IF (I.EQ.1) THEN + A(I) = 0.0D0 + ELSEIF (I.EQ.2) THEN + A(I) = 1.0D0 + ELSE + A(I) = A(I-1) + A(I-2) + ENDIF + ENDDO + END +C END FILE FIB1.F diff --git a/tests/packages/f77/pyproject.toml b/tests/packages/f77/pyproject.toml new file mode 100644 index 0000000..13dd52e --- /dev/null +++ b/tests/packages/f77/pyproject.toml @@ -0,0 +1,7 @@ +[build-system] +requires = ["scikit-build-core", "numpy", "f2py-cmake"] +build-backend = "scikit_build_core.build" + +[project] +name = "example" +version = "0.0.1" diff --git a/tests/test_package.py b/tests/test_package.py index 5b041ba..05d2c82 100644 --- a/tests/test_package.py +++ b/tests/test_package.py @@ -1,9 +1,32 @@ from __future__ import annotations import importlib.metadata +import zipfile +from pathlib import Path + +from scikit_build_core.build import build_wheel import f2py_cmake as m +DIR = Path(__file__).parent.resolve() + def test_version(): assert importlib.metadata.version("f2py_cmake") == m.__version__ + + +def test_f77(monkeypatch, tmp_path): + monkeypatch.chdir(DIR / "packages/f77") + build_dir = tmp_path / "build" + + wheel = build_wheel( + str(tmp_path), {"build-dir": str(build_dir), "wheel.license-files": []} + ) + + with zipfile.ZipFile(tmp_path / wheel) as f: + file_names = set(f.namelist()) + assert len(file_names) == 4 + + build_files = {x.name for x in build_dir.iterdir()} + assert "fibbymodule.c" in build_files + assert "fibby-f2pywrappers.f" in build_files