diff --git a/Dockerfile b/Dockerfile index f2c9430..7ca41bc 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,7 +7,10 @@ RUN apt-get update && \ apt-get install -y \ build-essential \ cmake \ - libgtest-dev && \ + libgtest-dev \ + pybind11-dev \ + python3-dev \ + python3-pybind11 && \ apt-get clean diff --git a/README.md b/README.md index d1b37b3..f19eaaa 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,28 @@ or by specifying one comparison policy and threshold (100ms for example), and re More usage please check the unittest. +## Python bindings + +The library can be used in Python via pybind11 bindings. +Since `util_caching` is a templated C++ library, + you need to explicitly instantiate the template for the types you want to use in Python. +For this, we provide convenience functions to bind the library for the desired types. +Simply call them in a pybind11 module definition, e.g.: + +```cpp +PYBIND11_MODULE(util_caching, m) { + python_api::number_based::bindCache(m); +} +``` +and use them in Python: + +```python +from util_caching import Cache +cache = Cache() +cache.cache(1.0, 2.0) +``` +We re-implemented all of the C++ unit tests in Python, so take a closer look at those for more advanced usage examples. + ## Installation @@ -111,7 +133,8 @@ find_package(util_caching REQUIRED) ### Building from source using CMake First make sure all dependencies are installed: -- [Googletest](https://github.com/google/googletest) (only if you want to build unit tests) +- [Googletest](https://github.com/google/googletest) (optional, if you want to build unit tests) +- [pybind11](https://pybind11.readthedocs.io/en/stable/) (optional, if you want to build Python bindings and unit tests) See also the [`Dockerfile`](./Dockerfile) for how to install these packages under Debian or Ubuntu. diff --git a/include/util_caching/python_bindings.hpp b/include/util_caching/python_bindings.hpp new file mode 100644 index 0000000..5d779da --- /dev/null +++ b/include/util_caching/python_bindings.hpp @@ -0,0 +1,151 @@ +#include + +#include +#include +#include + +#include "cache.hpp" + +namespace util_caching::python_api { + +namespace py = pybind11; + +namespace number_based { +namespace internal { + +/*! + * \brief Bind the comparison policies to the Cache class + * + * This function binds the comparison policies to the Cache class. The policies + * are passed as variadic template arguments. The function overloads the + * `cached` function for each policy. + */ +template +void bindPolicies(py::class_> &cacheClass) { + (cacheClass.def( + "cached", + [](CacheT &self, const NumberT &key, const ComparisonPolicyTs &policy) { + return self.template cached(key, policy); + }, + py::arg("key"), py::arg("policy")), + ...); +} +} // namespace internal + +/*! + * \brief Bind the ApproximateNumber policy + * + * This function adds bindings for the ApproximateNumber policy to the given + * python module under the given name. + */ +template +void bindApproximatePolicy(py::module &module, + const std::string &name = "ApproximateNumber") { + using ApproximateNumberT = policies::ApproximateNumber; + py::class_>( + module, name.c_str()) + .def(py::init(), py::arg("threshold")) + .def("__call__", &ApproximateNumberT::operator(), "Compare two numbers"); +} + +/*! + * \brief Bindings for a Cache that is based on number comparisons + * + * This function binds the Cache class for a specific number-based key type + * (NumberT) and value type (ValueT). Optionally, add a list of comparison + * policies to the list of template parameters. The `cached` function will be + * overloaded for each one of them. Call this function once inside + * PYBIND11_MODULE macro to create the bindings for the Cache class. + */ +template +void bindCache(py::module &module) { + using CacheT = Cache; + py::class_> cache(module, "Cache"); + cache + .def(py::init<>()) + // We cannot pass template parameters to python functions, therefore we + // need to explicitly bind all instantiations to different python + // functions. We need to use the lambdas here to handle the seconds + // argument, defining the comparison policy. + .def( + "cached", + [](CacheT &self, const NumberT &key) { + return self.template cached>(key); + }, + py::arg("key")) + .def("cache", &CacheT::cache, py::arg("key"), py::arg("value")) + .def("reset", &CacheT::reset); + + internal::bindPolicies(cache); +} + +} // namespace number_based + +namespace time_based { +namespace internal { + +/*! + * \brief Bind the comparison policies to the Cache class + * + * This function binds the comparison policies to the Cache class. The policies + * are passed as variadic template arguments. The function overloads the + * `cached` function for each policy. + */ +template +void bindPolicies(py::class_> &cache) { + (cache.def( + "cached", + [](CacheT &self, const TimeT &key, const ComparisonPolicyTs &policy) { + return self.template cached(key, policy); + }, + py::arg("key"), py::arg("policy")), + ...); +} +} // namespace internal + +/*! + * \brief Bind the ApproximateTime policy + * + * This function adds bindings for the ApproximateTime policy to the given + * python module under the given name. + */ +template +void bindApproximatePolicy(py::module &module, + const std::string &name = "ApproximateTime") { + using ApproximateTimeT = policies::ApproximateTime; + py::class_>(module, + name.c_str()) + .def(py::init(), py::arg("threshold")) + .def("__call__", &ApproximateTimeT::operator(), + "Compare two time points"); +} + +/*! + * \brief Bindings for a Cache that is based on time comparisons. + * + * This function binds the Cache class for a specific time-based key type + * (TimeT) and value type (ValueT). Optionally, add a list of comparison + * policies to the list of template parameters. The `cached` function will be + * overloaded for each one of them. Call this function once inside + * PYBIND11_MODULE macro to create the bindings for the Cache class. + */ +template +void bindCache(py::module &module) { + using CacheT = Cache; + + py::class_> cache(module, "Cache"); + cache.def(py::init<>()) + .def( + "cached", + [](CacheT &self, const TimeT &key) { + return self.template cached>(key); + }, + py::arg("key")) + .def("cache", &CacheT::cache, py::arg("key"), py::arg("value")) + .def("reset", &CacheT::reset); + + internal::bindPolicies(cache); +} + +} // namespace time_based +} // namespace util_caching::python_api diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 5f9639c..6d576cb 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -43,6 +43,11 @@ endif() ################### find_package(GTest) +find_package(pybind11 CONFIG) + +if(NOT GTEST_FOUND AND NOT pybind11_FOUND) + message(WARNING "Neither GTest nor pybind11 found. Cannot compile tests!") +endif() # Find installed lib and its dependencies, if this is build as top-level project if(CMAKE_PROJECT_NAME STREQUAL PROJECT_NAME) @@ -50,13 +55,14 @@ if(CMAKE_PROJECT_NAME STREQUAL PROJECT_NAME) endif() -########### -## Build ## -########### +#################### +## C++ Unit Tests ## +#################### if(GTEST_FOUND) file(GLOB_RECURSE _tests CONFIGURE_DEPENDS "*.cpp" "*.cc") list(FILTER _tests EXCLUDE REGEX "${CMAKE_CURRENT_BINARY_DIR}") + list(REMOVE_ITEM _tests "${CMAKE_CURRENT_SOURCE_DIR}/python_bindings.cpp") foreach(_test ${_tests}) get_filename_component(_test_name ${_test} NAME_WE) @@ -80,6 +86,42 @@ if(GTEST_FOUND) WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR} ) endforeach() -else() - message(WARNING "GTest not found. Cannot compile tests!") endif() + +####################### +## Python Unit Tests ## +####################### + +if(pybind11_FOUND) + # Find Python3 to run tests via ctest + find_package(Python3 REQUIRED) + + # Python bindings modules + pybind11_add_module(util_caching_py + python_bindings.cpp + ) + target_link_libraries(util_caching_py PUBLIC + util_caching + ) + + file(GLOB_RECURSE _py_tests CONFIGURE_DEPENDS "*.py") + + # Copy Python test files to build directory + foreach(_py_test ${_py_tests}) + get_filename_component(_py_test_name ${_py_test} NAME) + string(REGEX REPLACE "-test" "" PY_TEST_NAME ${_py_test_name}) + set(PY_TEST_NAME ${PROJECT_NAME}-pytest-${PY_TEST_NAME}) + + message(STATUS + "Adding python unittest \"${PY_TEST_NAME}\" with working dir ${PROJECT_SOURCE_DIR}/${TEST_FOLDER} \n _test: ${_py_test}" + ) + + configure_file(${_py_test} ${PY_TEST_NAME} COPYONLY) + + add_test(NAME ${PY_TEST_NAME} + COMMAND ${Python3_EXECUTABLE} ${PY_TEST_NAME} + WORKING_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR} + ) + endforeach() +endif() + diff --git a/test/python_bindings.cpp b/test/python_bindings.cpp new file mode 100644 index 0000000..0287770 --- /dev/null +++ b/test/python_bindings.cpp @@ -0,0 +1,60 @@ +#include + +#include "cache.hpp" +#include "python_bindings.hpp" +#include "types.hpp" + +namespace py = pybind11; + +using namespace util_caching; + +/*! + * \brief A policy that always returns true + * + * Custom policies have to be defined in C++ and then bound to Python. + * To overload the `cache` function, the policy has to be passed as a template parameter to the `bindCache` function. + */ +struct SomePolicyWithoutParams { + SomePolicyWithoutParams() = default; + bool operator()(const Time& /*lhs*/, const Time& /*rhs*/) const { + return true; + } +}; + +/*! + * \brief The python module definition that allows running python unit tests equivalent to the native C++ ones. + */ +PYBIND11_MODULE(util_caching_py, mainModule) { + // Just some aliases to make the code more readable + using ApproximateNumberT = policies::ApproximateNumber; + using ApproximateTimeT = policies::ApproximateTime; + using ApproximateTimeSecondsT = policies::ApproximateTime; + + // Since we want to use this policy in python, we need to be able to instatiate it there + py::class_>(mainModule, "SomePolicyWithoutParams") + .def(py::init<>()) + .def("__call__", &SomePolicyWithoutParams::operator()); + + // Adding a submodule is optional but a good way to structure the bindings + py::module numberBased = mainModule.def_submodule("number_based"); + // If we want to use a policy, we need to bind it. For the builtin policies, we can use this convenience function. + python_api::number_based::bindApproximatePolicy(numberBased); + // The core binding, the cache class itself. + python_api::number_based::bindCache(numberBased); + + // Same as above, but for the time-based cache + py::module timeBased = mainModule.def_submodule("time_based"); + // We can bind the builtin comparison policy for different time units but then we have to name them differently + python_api::time_based::bindApproximatePolicy(timeBased, "ApproximateTime"); + python_api::time_based::bindApproximatePolicy(timeBased, "ApproximateTimeSeconds"); + // The core binding, the cache class itself. + python_api::time_based::bindCache(timeBased); +} diff --git a/test/util_caching.py b/test/util_caching.py new file mode 100644 index 0000000..360e901 --- /dev/null +++ b/test/util_caching.py @@ -0,0 +1,95 @@ +import os +import unittest +import time + +from util_caching_py import number_based, time_based, SomePolicyWithoutParams + + +class CacheTest(unittest.TestCase): + def setUp(self): + self.key1 = 1.0 + self.key2 = 1.2 + self.key3 = 1.6 + self.time1 = time.time() + self.time2 = self.time1 + 0.01 # 10 milliseconds later + self.time3 = self.time1 + 1.1 # 1100 milliseconds later + self.time4 = self.time1 + 2.1 # 2100 milliseconds later + self.cache_by_number = number_based.Cache() + self.cache_by_time = time_based.Cache() + self.approximate_number_policy = number_based.ApproximateNumber(0.5) + self.approximate_time_policy = time_based.ApproximateTime(100) + self.approximate_time_policy_2 = time_based.ApproximateTimeSeconds(1) + self.dummy_policy = SomePolicyWithoutParams() + + def test_with_number_key(self): + self.assertIsNone(self.cache_by_number.cached(self.key1)) + self.cache_by_number.cache(self.key1, 1.0) + + # exact match + self.assertTrue(self.cache_by_number.cached(self.key1)) + self.assertEqual(self.cache_by_number.cached(self.key1), 1.0) + + # approximate match + self.assertTrue( + self.cache_by_number.cached(self.key2, self.approximate_number_policy) + ) + self.assertEqual( + self.cache_by_number.cached(self.key2, self.approximate_number_policy), + 1.0, + ) + + # over threshold + self.assertIsNone( + self.cache_by_number.cached(self.key3, self.approximate_number_policy) + ) + + def test_with_time_key(self): + self.assertIsNone(self.cache_by_time.cached(self.time1)) + self.cache_by_time.cache(self.time1, 1.0) + + # exact match + self.assertTrue(self.cache_by_time.cached(self.time1)) + self.assertEqual(self.cache_by_time.cached(self.time1), 1.0) + + # approximate match with milliseconds + self.assertTrue( + self.cache_by_time.cached(self.time2, self.approximate_time_policy) + ) + self.assertEqual( + self.cache_by_time.cached(self.time2, self.approximate_time_policy), 1.0 + ) + + # approximate match with seconds + self.assertTrue( + self.cache_by_time.cached(self.time2, self.approximate_time_policy_2) + ) + self.assertEqual( + self.cache_by_time.cached(self.time2, self.approximate_time_policy_2), 1.0 + ) + + # over threshold + self.assertIsNone( + self.cache_by_time.cached(self.time3, self.approximate_time_policy) + ) + # exactly 1s after rounding to integer + self.assertTrue( + self.cache_by_time.cached(self.time3, self.approximate_time_policy_2) + ) + # expect 2s after rounding to integer which is over threshold + self.assertIsNone( + self.cache_by_time.cached(self.time4, self.approximate_time_policy_2) + ) + + def test_with_other_comparison_policy(self): + self.cache_by_time.cache(self.time1, 1.0) + self.assertTrue(self.cache_by_time.cached(self.time2, self.dummy_policy)) + + +if __name__ == "__main__": + header = "Running " + os.path.basename(__file__) + + print("=" * len(header)) + print(header) + print("=" * len(header) + "\n") + unittest.main(exit=False) + print("=" * len(header) + "\n")