diff --git a/metatomic-core/include/metatomic/errors.hpp b/metatomic-core/include/metatomic/errors.hpp index 96c788ff6..b275750a3 100644 --- a/metatomic-core/include/metatomic/errors.hpp +++ b/metatomic-core/include/metatomic/errors.hpp @@ -24,19 +24,15 @@ namespace metatomic { inline void check_status(mta_status_t status) { if (status == MTA_SUCCESS) { return; - } else if (status == MTA_UNSUPPORTED_MODEL_ERROR) { - const char* message = nullptr; - const char* origin = nullptr; - void* data = nullptr; - mta_last_error(&message, &origin, &data); - if (origin != nullptr &&std::strcmp(origin, "C++ exception") == 0 && data != nullptr) { - std::rethrow_exception(*static_cast(data)); - } else { - throw Error(message == nullptr ? "unknown error" : message); - } + } + + const char* message = nullptr; + const char* origin = nullptr; + void* data = nullptr; + mta_last_error(&message, &origin, &data); + if (origin != nullptr && std::strcmp(origin, "C++ exception") == 0 && data != nullptr) { + std::rethrow_exception(*static_cast(data)); } else { - const char* message = nullptr; - mta_last_error(&message, nullptr, nullptr); throw Error(message == nullptr ? "unknown error" : message); } } @@ -54,7 +50,7 @@ namespace metatomic { } catch (...) { auto* exception_ptr = new std::exception_ptr(std::current_exception()); - const char* message = nullptr; + std::string message; try { std::rethrow_exception(*exception_ptr); } catch (const std::exception& e) { @@ -64,7 +60,7 @@ namespace metatomic { } auto status = mta_set_last_error( - message, + message.c_str(), "C++ exception", exception_ptr, [](void *ptr) { delete static_cast(ptr); } @@ -75,15 +71,11 @@ namespace metatomic { // but we should still try to report the original error // message if possible. std::fprintf(stderr, "INTERNAL ERROR: unable to set last error after C++ callback failure (status: %d). ", status); - if (message != nullptr) { - fprintf(stderr, "C++ error was: %s\n", message); - } else { - fprintf(stderr, "Unknown C++ error\n"); - } + std::fprintf(stderr, "C++ error was: %s\n", message.c_str()); delete exception_ptr; } - return MTA_UNSUPPORTED_MODEL_ERROR; + return MTA_MODEL_ERROR; } } @@ -96,10 +88,10 @@ namespace metatomic { const char* origin = nullptr; void* data = nullptr; mta_last_error(&message, &origin, &data); - if (std::strcmp(origin, "C++ exception") == 0 && data != nullptr) { + if (origin != nullptr && std::strcmp(origin, "C++ exception") == 0 && data != nullptr) { std::rethrow_exception(*static_cast(data)); } else { - throw Error(message); + throw Error(message == nullptr ? "unknown error" : message); } } } diff --git a/metatomic-core/include/metatomic/plugin.hpp b/metatomic-core/include/metatomic/plugin.hpp index 42300984f..5d4a33c34 100644 --- a/metatomic-core/include/metatomic/plugin.hpp +++ b/metatomic-core/include/metatomic/plugin.hpp @@ -1,9 +1,15 @@ #pragma once +#include +#include #include +#include + +#include #include #include +#include namespace metatomic { /// Load the shared library at `path` and register the plugin contained @@ -12,16 +18,15 @@ namespace metatomic { /// /// @param path path to the plugin shared library inline void load_plugin(const std::string& path) { - auto status = mta_load_plugin(path.c_str()); - details::check_status(status); + details::check_status(mta_load_plugin(path.c_str())); } /// Load a model from `load_from` with the given options. /// - /// If `plugin_name` is empty, metatomic will try to determine the correct - /// plugin to use by checking the `load_from` parameter. If we can not - /// determine the correct plugin, we then try to load the model with each - /// registered plugin until one succeeds. + /// If `plugin_name` is `std::nullopt`, metatomic will try to determine the + /// correct plugin to use by checking the `load_from` parameter. If we can + /// not determine the correct plugin, we then try to load the model with + /// each registered plugin until one succeeds. /// /// If `plugin_name` is given, then we only try to load the model with the /// specified plugin, and return an error if the plugin can not load the @@ -30,22 +35,122 @@ namespace metatomic { /// @param load_from where to load the model from (e.g. a file path, a /// model name, etc.) /// @param plugin_name optional name of the plugin to use for loading the - /// model, or empty to let metatomic search + /// model, or `std::nullopt` to let metatomic search /// @param options_json optional JSON object containing string keys and /// string values for loading the model /// @return the loaded model - inline mta_model_t load_model( + inline ExternalModel load_model( const std::string& load_from, - const std::string& options_json = "", - const std::string& plugin_name = "" + std::optional options_json = std::nullopt, + std::optional plugin_name = std::nullopt ) { mta_model_t model; - const char* plugin_name_ptr = plugin_name.empty() ? nullptr : plugin_name.c_str(); - const char* options_json_ptr = options_json.empty() ? nullptr : options_json.c_str(); + const char* plugin_name_ptr = nullptr; + if (plugin_name.has_value()) { + plugin_name_ptr = plugin_name->c_str(); + } + + const char* options_json_ptr = nullptr; + if (options_json.has_value()) { + options_json_ptr = options_json->c_str(); + } - auto status = mta_load_model(load_from.c_str(), options_json_ptr, plugin_name_ptr, &model); - details::check_status(status); + details::check_status(mta_load_model( + load_from.c_str(), + options_json_ptr, + plugin_name_ptr, + &model + )); - return model; + return ExternalModel(model); } + + namespace details { + using load_model_t = std::unique_ptr (*)( + const std::string& load_from, + const std::map& options + ); + + /// `load_model` callback for a `mta_plugin_t` built by `MTA_REGISTER_CXX_PLUGIN` + inline mta_status_t cxx_plugin_load_model( + const char* load_from, + const char* options_json, + mta_model_t* model, + load_model_t load_model_fn + ) { + std::unique_ptr cpp_model; + auto status = metatomic::details::catch_exceptions([&]() { + auto options_obj = nlohmann::json::parse(options_json); + auto options = options_obj.get>(); + cpp_model = load_model_fn(std::string(load_from), options); + }); + + if (status != MTA_SUCCESS) { + // exception was caught while trying to load the model + // stop the plugin search and report the error + return status; + } + + if (cpp_model == nullptr) { + // the plugin could not load this model + return MTA_UNSUPPORTED_MODEL_ERROR; + } + + *model = BaseModel::to_mta_model(std::move(cpp_model)); + return MTA_SUCCESS; + } + } // namespace details } // namespace metatomic + + +#ifdef __clang__ +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wunused-macros" +#endif + + +/// Plugin entry point for a C++ plugin. +/// +/// This should be used once in a C++ plugin shared library. `plugin_name` is +/// the name of the plugin, and `load_model_fn` must be a plain function with +/// the signature: +/// +/// ```cpp +/// std::unique_ptr load_model_fn( +/// const std::string& load_from, +/// const std::map& options +/// ); +/// ``` +/// +/// `load_model_fn` MUST return `nullptr` if it can not load the model +/// described by `load_from`, so metatomic can try another plugin. +/// +/// Only one `MTA_REGISTER_CXX_PLUGIN` can be used per shared library. +#define MTA_REGISTER_CXX_PLUGIN(plugin_name, load_model_fn) \ + MTA_REGISTER_PLUGIN(register_fn, { \ + static_assert( \ + std::is_convertible< \ + decltype(load_model_fn), metatomic::details::load_model_t \ + >::value, \ + "MTA_REGISTER_CXX_PLUGIN: load_model_fn must be callable as " \ + "std::unique_ptr(" \ + "std::string, std::map)" \ + ); \ + \ + static mta_plugin_t PLUGIN { \ + /*abi_version*/ MTA_ABI_VERSION, \ + /*name*/ plugin_name, \ + /*load_model*/ []( \ + const char* load_from, const char* options_json, mta_model_t* model \ + ) { \ + return metatomic::details::cxx_plugin_load_model( \ + load_from, options_json, model, load_model_fn \ + ); \ + } \ + }; \ + return register_fn(PLUGIN); \ + }) + +#ifdef __clang__ +#pragma clang diagnostic pop +#endif diff --git a/metatomic-core/tests/cxx/model.cpp b/metatomic-core/tests/cxx/model.cpp index 48d0756ef..971f078db 100644 --- a/metatomic-core/tests/cxx/model.cpp +++ b/metatomic-core/tests/cxx/model.cpp @@ -1,5 +1,6 @@ #include #include +#include #include #include @@ -323,3 +324,56 @@ TEST_CASE("mta_model_view does not take ownership") { CHECK(model_view_external.unload == nullptr); CHECK(raw->unload != nullptr); } + +class ThrowingModel: public metatomic::BaseModel { +public: + [[noreturn]] metatomic::ModelCapabilities capabilities() const final { + throw std::out_of_range("ThrowingCppModel: intentional failure in capabilities"); + } + + metatomic::ModelMetadata metadata() const final { + return metatomic::ModelMetadata::builder() + .name("throwing C++ model") + .build(); + } + + std::vector requested_pair_lists() const final { + return {}; + } + + std::vector requested_inputs() const final { + return {}; + } + + std::vector execute_inner( + const std::vector&, + const metatensor::Labels*, + const std::vector& + ) final { + return {}; + } +}; + + +TEST_CASE("C++ exception") { + auto raw = metatomic::BaseModel::to_mta_model( + std::make_unique() + ); + + // An exception thrown by a C++ model is reported as `MTA_MODEL_ERROR` + mta_string_t capabilities_json = nullptr; + auto status = raw.capabilities(raw.data, &capabilities_json); + CHECK(status == MTA_MODEL_ERROR); + CHECK(status != MTA_UNSUPPORTED_MODEL_ERROR); + CHECK(capabilities_json == nullptr); + + const char* message = nullptr; + const char* origin = nullptr; + mta_last_error(&message, &origin, nullptr); + CHECK(std::string(origin) == "C++ exception"); + CHECK(std::string(message) == "ThrowingCppModel: intentional failure in capabilities"); + + // Going back through the C++ API rethrows the original exception + auto model = metatomic::ExternalModel(raw); + CHECK_THROWS_AS(model.capabilities(), std::out_of_range); +} diff --git a/metatomic-core/tests/cxx/plugins.cpp b/metatomic-core/tests/cxx/plugins.cpp index 92be8995c..fe67ade2e 100644 --- a/metatomic-core/tests/cxx/plugins.cpp +++ b/metatomic-core/tests/cxx/plugins.cpp @@ -1,9 +1,11 @@ +#include + #include #include "metatomic.hpp" -TEST_CASE("Load plugins") { +TEST_CASE("Load C plugins") { metatomic::load_plugin(PLUGIN_DIR "/test-c-plugin.so"); REQUIRE_THROWS_WITH( @@ -23,3 +25,46 @@ TEST_CASE("Load plugins") { "plugin ABI version is 2, but metatomic expects 1" ); } + + +TEST_CASE("Load C++ plugins") { + metatomic::load_plugin(PLUGIN_DIR "/test-cxx-plugin.so"); + + auto model = metatomic::load_model("test-cxx-model", "{}", "test-cxx-plugin"); + + auto metadata = model.metadata(); + CHECK(metadata.name() == "simple C++ plugin model"); + + auto capabilities = model.capabilities(); + CHECK(capabilities.length_unit() == "nm"); + REQUIRE(capabilities.outputs().size() == 1); + CHECK(capabilities.outputs()[0].name() == "energy"); + + REQUIRE_THROWS_WITH( + metatomic::load_model("unknown", "{}", "test-cxx-plugin"), + "invalid parameter: failed to load model from 'unknown': plugin " + "'test-cxx-plugin' could not load the model" + ); + + // an exception thrown by the plugin is reported as `MTA_MODEL_ERROR`, + // the actual exception makes it back to the caller + REQUIRE_THROWS_WITH( + metatomic::load_model("throws", "{}", "test-cxx-plugin"), + "load_model_cxx: intentional failure for 'throws'" + ); + CHECK_THROWS_AS( + metatomic::load_model("throws", "{}", "test-cxx-plugin"), + metatomic::Error + ); + + CHECK_THROWS_AS( + metatomic::load_model("throws-std", "{}", "test-cxx-plugin"), + std::out_of_range + ); + + REQUIRE_THROWS_WITH( + metatomic::load_model("unknown"), + Catch::Contains("tried the following plugins, but none could load the model") + && Catch::Contains("test-cxx-plugin") + ); +} diff --git a/metatomic-core/tests/test-plugins/CMakeLists.txt b/metatomic-core/tests/test-plugins/CMakeLists.txt index 2693ceab7..9ebeea89d 100644 --- a/metatomic-core/tests/test-plugins/CMakeLists.txt +++ b/metatomic-core/tests/test-plugins/CMakeLists.txt @@ -1,6 +1,8 @@ +# create test plugins with a consistent name across platforms +# .so suffix is used for all the platforms + add_library(test-c-plugin SHARED plugin.c) target_link_libraries(test-c-plugin metatomic) -# create test plugins with a consistent name across platforms set_target_properties(test-c-plugin PROPERTIES PREFIX "" SUFFIX ".so" @@ -12,3 +14,10 @@ set_target_properties(bad-abi-plugin PROPERTIES PREFIX "" SUFFIX ".so" ) + +add_library(test-cxx-plugin SHARED cxx-plugin.cpp) +target_link_libraries(test-cxx-plugin metatomic) +set_target_properties(test-cxx-plugin PROPERTIES + PREFIX "" + SUFFIX ".so" +) diff --git a/metatomic-core/tests/test-plugins/cxx-plugin.cpp b/metatomic-core/tests/test-plugins/cxx-plugin.cpp new file mode 100644 index 000000000..5c1c12e77 --- /dev/null +++ b/metatomic-core/tests/test-plugins/cxx-plugin.cpp @@ -0,0 +1,78 @@ +#include +#include +#include +#include +#include + +#include +#include "metatomic.hpp" + + +class SimpleModel: public metatomic::BaseModel { +public: + metatomic::ModelCapabilities capabilities() const final { + return metatomic::ModelCapabilities::builder() + .atomic_types({1, 6, 8}) + .interaction_range(4.5) + .length_unit("nm") + .supported_devices({metatomic::ModelCapabilities::Device::CPU}) + .dtype(metatomic::ModelCapabilities::DType::Float32) + .add_output(metatomic::Quantity::builder() + .name("energy") + .unit("eV") + .sample_kind(metatomic::SampleKind::System) + .build()) + .build(); + } + + metatomic::ModelMetadata metadata() const final { + return metatomic::ModelMetadata::builder() + .name("simple C++ plugin model") + .description("test model for MTA_REGISTER_CXX_PLUGIN") + .build(); + } + + std::vector requested_pair_lists() const final { + return {}; + } + + std::vector requested_inputs() const final { + return {}; + } + + std::vector execute_inner( + const std::vector&, + const metatensor::Labels*, + const std::vector& + ) final { + return {}; + } +}; + + +std::unique_ptr load_model_cxx( + const std::string& load_from, + const std::map& options +) { + (void)options; + + if (load_from == "throws") { + throw metatomic::Error("load_model_cxx: intentional failure for '" + load_from + "'"); + } + + if (load_from == "throws-std") { + // exceptions that are not `metatomic::Error` should also make it back + // to the caller with their original type + throw std::out_of_range("load_model_cxx: intentional failure for '" + load_from + "'"); + } + + if (load_from != "test-cxx-model") { + // this plugin can not load this model + return nullptr; + } + + return std::make_unique(); +} + + +MTA_REGISTER_CXX_PLUGIN("test-cxx-plugin", load_model_cxx);