diff --git a/.gitignore b/.gitignore index b475b03..e41aae7 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,5 @@ build*/ __pycache__/ venv/ .cache/ +.direnv/ +.ccls-cache/ diff --git a/.travis.yml b/.travis.yml index a602b30..db179d0 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,69 +1,79 @@ -language: python - sudo: false +dist: bionic + +env: + global: + - PIPENV_IGNORE_VIRTUALENVS=1 + - PATH=$HOME/Deps/cmake/bin${PATH:+:$PATH} + matrix: include: - - os: linux - compiler: gcc - addons: &gcc49 + - name: "Context API example on Linux" + os: linux + language: python + python: 3.7 + addons: apt: - sources: ['ubuntu-toolchain-r-test'] - packages: ['g++-4.9', 'gcc-4.9', 'gfortran-4.9'] + packages: ['gfortran'] env: - - CXX='g++-4.9' - - CC='gcc-4.9' - - FC='gfortran-4.9' - python: 2.7 - - os: linux - compiler: gcc - addons: &gcc49 - apt: - sources: ['ubuntu-toolchain-r-test'] - packages: ['g++-4.9', 'gcc-4.9', 'gfortran-4.9'] + - CXX_COMPILER='g++' + - C_COMPILER='gcc' + - FC_COMPILER='gfortran' + - name: "Context API example on macOS" + os: osx + osx_image: xcode11.2 # Python 3.7.4 running on macOS 10.14.4 + language: shell # 'language: python' is an error on Travis CI macOS + addons: + homebrew: + packages: + - gcc + - cmake env: - - CXX='g++-4.9' - - CC='gcc-4.9' - - FC='gfortran-4.9' - python: 3.6 - - os: osx - osx_image: xcode7.3 - compiler: gcc - sudo: required - language: generic + - CXX_COMPILER='g++' + - C_COMPILER='gcc' + - FC_COMPILER='gfortran' + +before_install: + - mkdir -p $HOME/Downloads $HOME/Deps install: - | - if [[ "${TRAVIS_OS_NAME}" == "osx" ]]; then - # manually install python on osx - brew update &> /dev/null - brew install python3 - brew reinstall gcc - virtualenv venv - source venv/bin/activate - pip install -r requirements.txt --upgrade + if [[ "$TRAVIS_OS_NAME" == "linux" ]]; then + CMAKE_VERSION="3.14.7" + echo "-- Installing CMake ${CMAKE_VERSION}" + target_path=$HOME/Deps/cmake + cmake_url="https://cmake.org/files/v${CMAKE_VERSION%.*}/cmake-${CMAKE_VERSION}-Linux-x86_64.tar.gz" + mkdir -p "$target_path" + curl -Ls "$cmake_url" | tar -xz -C "$target_path" --strip-components=1 + echo "-- Done with CMake ${CMAKE_VERSION}" fi - - pip install -r requirements.txt --upgrade - - python --version + - pip install --upgrade pip + - pip install -U pipenv + - pipenv install --dev before_script: - - pycodestyle account/ - - pycodestyle test/test.py + - test -n $CXX && unset CXX + - test -n $CC && unset CC + - test -n $FC && unset FC script: - - mkdir build + - pipenv run pycodestyle account/ + - pipenv run pycodestyle test/test_account.py + - pipenv run cmake -H. -Bbuild -DCMAKE_CXX_COMPILER=${CXX_COMPILER} -DCMAKE_C_COMPILER=${C_COMPILER} -DCMAKE_Fortran_COMPILER=${FC_COMPILER} + - pipenv run cmake --build build -- VERBOSE=1 - cd build - - cmake .. - - make - - make test + - ctest + - pipenv run python -m pytest -rws -vv lib/python3.7 - cd .. - - ACCOUNT_LIBRARY_DIR=$PWD/build/lib ACCOUNT_INCLUDE_DIR=$PWD/account PYTHONPATH=$PWD pytest -vv test/test.py +# in the remaining part test that we can pip install the code - mkdir test-setup-script - cd test-setup-script - virtualenv venv - source venv/bin/activate - - pip install git+https://github.com/bast/context-api-example.git + - pip install /home/travis/build/dev-cafe/context-api-example/ - python -c 'import account' + - deactivate notifications: email: false diff --git a/CMakeLists.txt b/CMakeLists.txt index 9b5d8a0..a46757a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,14 +1,28 @@ # define minimum cmake version -cmake_minimum_required(VERSION 2.8 FATAL_ERROR) +cmake_minimum_required(VERSION 3.14) # project name and supported languages -project(example CXX Fortran) +project(example LANGUAGES CXX Fortran C) + +include(GNUInstallDirs) # require C++11 set(CMAKE_CXX_STANDARD 11) set(CMAKE_CXX_EXTENSIONS OFF) set(CMAKE_CXX_STANDARD_REQUIRED ON) +# require C99 +set(CMAKE_C_STANDARD 99) +set(CMAKE_C_EXTENSIONS OFF) +set(CMAKE_C_STANDARD_REQUIRED ON) + +# specify where to place libraries +set(CMAKE_LIBRARY_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/lib) + +# Python is required to get the Python interface working +find_package(Python 3.7 REQUIRED COMPONENTS Development Interpreter) +file(TO_NATIVE_PATH "lib/python${Python_VERSION_MAJOR}.${Python_VERSION_MINOR}/account" PYMOD_INSTALL_FULLDIR) + # interface and sources add_subdirectory(account) diff --git a/Pipfile b/Pipfile new file mode 100644 index 0000000..b6dff76 --- /dev/null +++ b/Pipfile @@ -0,0 +1,11 @@ +[[source]] +name = "pypi" +url = "https://pypi.org/simple" +verify_ssl = true + +[dev-packages] +pytest = "*" +cffi = "*" +pycodestyle = "*" + +[packages] \ No newline at end of file diff --git a/account/CMakeLists.txt b/account/CMakeLists.txt index 945f8b8..5f4b7d5 100644 --- a/account/CMakeLists.txt +++ b/account/CMakeLists.txt @@ -1,16 +1,121 @@ -# specify where to place libraries -set(CMAKE_LIBRARY_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/lib) +# Get file extension for Python module +execute_process( + COMMAND + "${Python_EXECUTABLE}" "-c" "from distutils import sysconfig as s;print(s.get_config_var('EXT_SUFFIX'))" + RESULT_VARIABLE _PYTHON_SUCCESS + OUTPUT_VARIABLE Python_MODULE_EXTENSION + ERROR_VARIABLE _PYTHON_ERROR_VALUE + OUTPUT_STRIP_TRAILING_WHITESPACE + ) + +# Call CFFI to generate bindings source file _account.cc +file(MAKE_DIRECTORY ${PROJECT_BINARY_DIR}/generated) +add_custom_command( + OUTPUT + ${PROJECT_BINARY_DIR}/generated/_account.c + COMMAND + ${Python_EXECUTABLE} ${CMAKE_CURRENT_LIST_DIR}/builder.py + MAIN_DEPENDENCY + ${CMAKE_CURRENT_LIST_DIR}/builder.py + DEPENDS + ${CMAKE_CURRENT_LIST_DIR}/account.f90 + ${CMAKE_CURRENT_LIST_DIR}/account.h + WORKING_DIRECTORY + ${PROJECT_BINARY_DIR}/generated + ) + +add_custom_target( + cffi-builder + ALL + DEPENDS + ${PROJECT_BINARY_DIR}/generated/_account.c + ) + +Python_add_library(_account + MODULE + account.f90 + ${PROJECT_BINARY_DIR}/generated/_account.c + ) + +add_dependencies(_account cffi-builder) + +# generate account_export.h +include(GenerateExportHeader) +generate_export_header(_account + BASE_NAME account + ) + +target_include_directories(_account + PRIVATE + ${CMAKE_CURRENT_LIST_DIR} # where account.h lives + ${CMAKE_CURRENT_BINARY_DIR} # where lsdalton_py_export.h lives + ) # implementation sources add_subdirectory(implementation) +target_link_libraries(_account + PUBLIC + account_fortran_implementation + ) + # fortran interface add_library(fortran_c_interface account.f90) -install( - FILES - account.h - account.f90 - DESTINATION - include +# RPATH fixing +file(RELATIVE_PATH _rel ${CMAKE_INSTALL_PREFIX}/${PYMOD_INSTALL_FULLDIR} ${CMAKE_INSTALL_PREFIX}) +if(APPLE) + set(_RPATH "@loader_path/${_rel}") +else() + set(_RPATH "\$ORIGIN/${_rel}") +endif() + +# List of Python files around the compiled extension +list(APPEND _pys + ${CMAKE_CURRENT_SOURCE_DIR}/__init__.py + ${CMAKE_CURRENT_SOURCE_DIR}/shim.py + ) + +set_target_properties(_account + PROPERTIES + SUFFIX "${Python_MODULE_EXTENSION}" + MACOSX_RPATH ON + SKIP_BUILD_RPATH OFF + BUILD_WITH_INSTALL_RPATH OFF + INSTALL_RPATH "${_RPATH}${CMAKE_INSTALL_LIBDIR}" + INSTALL_RPATH_USE_LINK_PATH ON + LIBRARY_OUTPUT_DIRECTORY ${PROJECT_BINARY_DIR}/${PYMOD_INSTALL_FULLDIR} + RESOURCE "${_pys}" + ) + +# Create symlinks into build tree +file(MAKE_DIRECTORY ${PROJECT_BINARY_DIR}/${PYMOD_INSTALL_FULLDIR}) +foreach(_py IN LISTS _pys) + get_filename_component(__py ${_py} NAME) + file( + CREATE_LINK + ${_py} + ${PROJECT_BINARY_DIR}/${PYMOD_INSTALL_FULLDIR}/${__py} + SYMBOLIC ) +endforeach() + +install( + FILES + account.h + account.f90 + ${CMAKE_CURRENT_BINARY_DIR}/account_export.h + DESTINATION + ${CMAKE_INSTALL_INCLUDEDIR} + ) + +install( + TARGETS + _account + LIBRARY + DESTINATION ${PYMOD_INSTALL_FULLDIR}/lib + RUNTIME + DESTINATION ${PYMOD_INSTALL_FULLDIR}/lib + RESOURCE + DESTINATION ${PYMOD_INSTALL_FULLDIR} + ) diff --git a/account/__init__.py b/account/__init__.py index 54f6068..6249938 100644 --- a/account/__init__.py +++ b/account/__init__.py @@ -1,34 +1,5 @@ -from .cffi_helpers import get_lib_handle -import os -import sys +from .shim import * - -def get_env(v): - _v = os.getenv(v) - if _v is None: - sys.stderr.write('ERROR: variable {0} is undefined\n'.format(v)) - sys.exit(1) - return _v - - -_this_path = os.path.dirname(os.path.realpath(__file__)) - -_library_dir = os.getenv('ACCOUNT_LIBRARY_DIR') -if _library_dir is None: - _library_dir = os.path.join(_this_path, 'lib') - -_include_dir = os.getenv('ACCOUNT_INCLUDE_DIR') -if _include_dir is None: - _include_dir = os.path.join(_this_path, 'include') - -c_lib = get_lib_handle(['-DACCOUNT_API=', '-DACCOUNT_NOINCLUDE'], - 'account.h', - 'account_cpp_implementation', - _library_dir, - _include_dir) - -f_lib = get_lib_handle(['-DACCOUNT_API=', '-DACCOUNT_NOINCLUDE'], - 'account.h', - 'account_fortran_implementation', - _library_dir, - _include_dir) +__all__ = [ + "Account", +] diff --git a/account/account.h b/account/account.h index 1e40d00..0905af5 100644 --- a/account/account.h +++ b/account/account.h @@ -1,4 +1,6 @@ -#pragma once +/* CFFI would issue warning with pragma once */ +#ifndef ACCOUNT_H_INCLUDED +#define ACCOUNT_H_INCLUDED #ifndef ACCOUNT_API #include "account_export.h" @@ -6,7 +8,8 @@ #endif #ifdef __cplusplus -extern "C" { +extern "C" +{ #endif struct account_context; @@ -30,3 +33,5 @@ double account_get_balance(const account_context_t *context); #ifdef __cplusplus } #endif + +#endif /* ACCOUNT_H_INCLUDED */ diff --git a/account/builder.py b/account/builder.py new file mode 100644 index 0000000..6a32279 --- /dev/null +++ b/account/builder.py @@ -0,0 +1,34 @@ +#!/usr/bin/env python + +from pathlib import Path +from subprocess import check_output + +from cffi import FFI + +ffibuilder = FFI() + +definitions = ["-DACCOUNT_API=", "-DACCOUNT_NOINCLUDE"] +header = Path(__file__).resolve().parent / "account.h" +command = ["cc", "-E"] + definitions + [str(header)] +interface = check_output(command).decode("utf-8") + +# remove possible \r characters on windows which +# would confuse cdef +_interface = [l.strip("\r") for l in interface.split("\n")] + +# cdef() expects a single string declaring the C types, functions and +# globals needed to use the shared object. It must be in valid C syntax. +ffibuilder.cdef("\n".join(_interface)) + +# set_source() gives the name of the python extension module to +# produce, and some C source code as a string. This C code needs +# to make the declared functions, types and globals available, +# so it is often just the "#include". +ffibuilder.set_source( + "_account", + """ + #include "account.h" +""", +) + +ffibuilder.emit_c_code("_account.c") diff --git a/account/implementation/CMakeLists.txt b/account/implementation/CMakeLists.txt index 6ef386a..2941a74 100644 --- a/account/implementation/CMakeLists.txt +++ b/account/implementation/CMakeLists.txt @@ -9,7 +9,7 @@ target_include_directories( account_cpp_implementation PRIVATE ${PROJECT_SOURCE_DIR}/account - ${PROJECT_BINARY_DIR}/account/implementation + ${PROJECT_BINARY_DIR}/account ) add_library( @@ -18,20 +18,6 @@ add_library( fortran_implementation.f90 ) -# generate account_export.h -include(GenerateExportHeader) -generate_export_header( - account_cpp_implementation - BASE_NAME account - ) - -install( - FILES - ${PROJECT_BINARY_DIR}/account/implementation/account_export.h - DESTINATION - include - ) - install( TARGETS account_cpp_implementation diff --git a/account/shim.py b/account/shim.py new file mode 100644 index 0000000..b8adcb2 --- /dev/null +++ b/account/shim.py @@ -0,0 +1,25 @@ +from dataclasses import dataclass, field +from typing import Any + +from ._account import lib + + +@dataclass +class Account: + _context: Any = field(repr=False, init=False, compare=False) + + def __post_init__(self): + self._context = lib.account_new() + + def __del__(self): + lib.account_free(self._context) + + @property + def balance(self) -> float: + return lib.account_get_balance(self._context) + + def deposit(self, amount: float) -> None: + lib.account_deposit(self._context, amount) + + def withdraw(self, amount: float) -> None: + lib.account_withdraw(self._context, amount) diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 9c03204..0000000 --- a/requirements.txt +++ /dev/null @@ -1,3 +0,0 @@ -pytest -cffi -pycodestyle diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 48c8e1b..64422b6 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -3,7 +3,7 @@ target_include_directories( c_testing_c PRIVATE ${PROJECT_SOURCE_DIR}/account - ${PROJECT_BINARY_DIR}/account/implementation + ${PROJECT_BINARY_DIR}/account ) target_link_libraries(c_testing_c account_cpp_implementation) @@ -12,7 +12,7 @@ target_include_directories( c_testing_fortran PRIVATE ${PROJECT_SOURCE_DIR}/account - ${PROJECT_BINARY_DIR}/account/implementation + ${PROJECT_BINARY_DIR}/account ) target_link_libraries(c_testing_fortran account_fortran_implementation) @@ -35,3 +35,27 @@ target_link_libraries(fortran_testing_fortran account_fortran_implementation for foreach(_test c_testing_c c_testing_fortran fortran_testing_c fortran_testing_fortran) add_test(${_test} ${PROJECT_BINARY_DIR}/test/${_test}) endforeach() + +file( + CREATE_LINK + ${CMAKE_CURRENT_SOURCE_DIR}/test_account.py + ${PROJECT_BINARY_DIR}/${PYMOD_INSTALL_FULLDIR}/../test_account.py + SYMBOLIC + ) + +# FIXME Does not work +#add_test( +# NAME +# cffi-test +# COMMAND +# ${Python_EXECUTABLE} -m pytest -rws -vv ${PROJECT_BINARY_DIR}/${PYMOD_INSTALL_FULLDIR}/../test_account.py +# WORKING_DIRECTORY +# ${CMAKE_CURRENT_BINARY_DIR} +# ) + +install( + FILES + test_account.py + DESTINATION + ${PYMOD_INSTALL_FULLDIR}/.. + ) diff --git a/test/test.py b/test/test.py deleted file mode 100644 index ed2db6d..0000000 --- a/test/test.py +++ /dev/null @@ -1,28 +0,0 @@ -def _test_implementation(lib): - account1 = lib.account_new() - - lib.account_deposit(account1, 100.0) - lib.account_deposit(account1, 100.0) - - account2 = lib.account_new() - - lib.account_deposit(account2, 200.0) - lib.account_deposit(account2, 200.0) - - lib.account_withdraw(account1, 50.0) - - assert lib.account_get_balance(account1) == 150.0 - lib.account_free(account1) - - assert lib.account_get_balance(account2) == 400.0 - lib.account_free(account2) - - -def test_fortran(): - from account import f_lib - _test_implementation(f_lib) - - -def test_cpp(): - from account import c_lib - _test_implementation(c_lib) diff --git a/test/test_account.py b/test/test_account.py new file mode 100644 index 0000000..867adf7 --- /dev/null +++ b/test/test_account.py @@ -0,0 +1,19 @@ +import pytest + +from account import Account + + +def test_account(): + account1 = Account() + account1.deposit(100.0) + account1.deposit(100.0) + + account2 = Account() + account2.deposit(200.0) + account2.deposit(200.0) + + account1.withdraw(50.0) + + assert account1.balance == pytest.approx(150.0) + + assert account2.balance == pytest.approx(400.0)