diff --git a/python/engine/CMakeLists.txt b/python/engine/CMakeLists.txt index 27d5cd211d0..1343becdea3 100644 --- a/python/engine/CMakeLists.txt +++ b/python/engine/CMakeLists.txt @@ -44,6 +44,20 @@ target_compile_options(pythonengine PRIVATE target_compile_definitions(pythonengine PRIVATE openstudio_scriptengine_EXPORTS SHARED_OS_LIBS) +if(BUILD_TESTING) + set(pythonengine_test_depends + openstudio_scriptengine + openstudiolib + CONAN_PKG::fmt + ) + + set(pythonengine_test_src + test/PythonEngine_GTest.cpp + ) + + CREATE_TEST_TARGETS(pythonengine "${pythonengine_test_src}" "${pythonengine_test_depends}") +endif() + install(TARGETS pythonengine EXPORT openstudio DESTINATION ${LIB_DESTINATION_DIR} COMPONENT "CLI") # it goes into lib/ and we want to find: diff --git a/python/engine/test/BadMeasure/measure.py b/python/engine/test/BadMeasure/measure.py new file mode 100644 index 00000000000..67471e8588f --- /dev/null +++ b/python/engine/test/BadMeasure/measure.py @@ -0,0 +1,33 @@ +import typing + +import openstudio + + +class BadMeasure(openstudio.measure.ModelMeasure): + def name(self): + return "Bad Measure" + + def modeler_description(self): + return "The arguments method calls another_method which does a raise ValueError" + + def another_method(self): + raise ValueError("oops") + + def arguments(self, model: typing.Optional[openstudio.model.Model] = None): + self.another_method() + args = openstudio.measure.OSArgumentVector() + + return args + + def run( + self, + model: openstudio.model.Model, + runner: openstudio.measure.OSRunner, + user_arguments: openstudio.measure.OSArgumentMap, + ): + """ + define what happens when the measure is run + """ + super().run(model, runner, user_arguments) # Do **NOT** remove this line + +BadMeasure().registerWithApplication() diff --git a/python/engine/test/BadMeasure/measure.xml b/python/engine/test/BadMeasure/measure.xml new file mode 100644 index 00000000000..3b0e0be4172 --- /dev/null +++ b/python/engine/test/BadMeasure/measure.xml @@ -0,0 +1,59 @@ + + + 3.1 + bad_measure + 812d3ebf-c89b-4b93-b400-110ca060b2bb + 25ad8ea8-b28b-4f45-93a6-76f056c28ca8 + 2023-11-10T10:47:04Z + 33A29C78 + BadMeasure + Bad Measure + + The arguments method calls another_method which does a raise ValueError + + + + + Envelope.Opaque + + + + Measure Function + Measure + string + + + Requires EnergyPlus Results + false + boolean + + + Measure Type + ModelMeasure + string + + + Measure Language + Python + string + + + Uses SketchUp API + false + boolean + + + + + + OpenStudio + 3.4.1 + 3.4.1 + + measure.py + py + script + E787E0E0 + + + diff --git a/python/engine/test/PythonEngine_GTest.cpp b/python/engine/test/PythonEngine_GTest.cpp new file mode 100644 index 00000000000..6c44bf739bb --- /dev/null +++ b/python/engine/test/PythonEngine_GTest.cpp @@ -0,0 +1,136 @@ +/*********************************************************************************************************************** +* OpenStudio(R), Copyright (c) Alliance for Sustainable Energy, LLC. +* See also https://openstudio.net/license +***********************************************************************************************************************/ + +#include + +#include "measure/ModelMeasure.hpp" +#include "measure/OSArgument.hpp" +#include "measure/OSMeasure.hpp" +#include "model/Model.hpp" +#include "scriptengine/ScriptEngine.hpp" + +#include + +class PythonEngineFixture : public testing::Test +{ + public: + static openstudio::path getScriptPath(const std::string& classAndDirName) { + openstudio::path scriptPath = openstudio::getApplicationSourceDirectory() / fmt::format("python/engine/test/{}/measure.py", classAndDirName); + OS_ASSERT(openstudio::filesystem::is_regular_file(scriptPath)); + return scriptPath; + } + + protected: + // initialize for each test + virtual void SetUp() override { + std::vector args; + thisEngine = std::make_unique("pythonengine", args); + + (*thisEngine)->registerType("openstudio::measure::OSMeasure *"); + (*thisEngine)->registerType("openstudio::measure::ModelMeasure *"); + } + // tear down after each test + virtual void TearDown() override { + // Want to ensure the engine is reset regardless of the test outcome, or it may throw a segfault + thisEngine->reset(); + } + + std::unique_ptr thisEngine; +}; + +TEST_F(PythonEngineFixture, BadMeasure) { + + const std::string classAndDirName = "BadMeasure"; + + const auto scriptPath = getScriptPath(classAndDirName); + auto measureScriptObject = (*thisEngine)->loadMeasure(scriptPath, classAndDirName); + auto* measurePtr = (*thisEngine)->getAs(measureScriptObject); + + ASSERT_EQ(measurePtr->name(), "Bad Measure"); + + std::string expected_exception = fmt::format(R"(SWIG director method error. In method 'arguments': `ValueError('oops')` + +Traceback (most recent call last): + File "{0}", line 17, in arguments + self.another_method() + File "{0}", line 14, in another_method + raise ValueError("oops") +ValueError: oops)", + scriptPath.generic_string()); + + openstudio::model::Model model; + try { + measurePtr->arguments(model); + ASSERT_FALSE(true) << "Expected measure arguments(model) to throw"; + } catch (std::exception& e) { + std::string error = e.what(); + EXPECT_EQ(expected_exception, error); + } +} + +TEST_F(PythonEngineFixture, WrongMethodMeasure) { + + const std::string classAndDirName = "WrongMethodMeasure"; + + const auto scriptPath = getScriptPath(classAndDirName); + auto measureScriptObject = (*thisEngine)->loadMeasure(scriptPath, classAndDirName); + auto* measurePtr = (*thisEngine)->getAs(measureScriptObject); + + ASSERT_EQ(measurePtr->name(), "Wrong Method Measure"); + + std::string expected_exception = + fmt::format(R"(SWIG director method error. In method 'arguments': `AttributeError("'Model' object has no attribute 'nonExistingMethod'")` + +Traceback (most recent call last): + File "{}", line 14, in arguments + model.nonExistingMethod() +AttributeError: 'Model' object has no attribute 'nonExistingMethod')", + scriptPath.generic_string()); + + openstudio::model::Model model; + try { + measurePtr->arguments(model); + ASSERT_FALSE(true) << "Expected measure arguments(model) to throw"; + } catch (std::exception& e) { + std::string error = e.what(); + EXPECT_EQ(expected_exception, error); + } +} + +TEST_F(PythonEngineFixture, StackLevelTooDeepMeasure) { + + const std::string classAndDirName = "StackLevelTooDeepMeasure"; + + const auto scriptPath = getScriptPath(classAndDirName); + auto measureScriptObject = (*thisEngine)->loadMeasure(scriptPath, classAndDirName); + auto* measurePtr = (*thisEngine)->getAs(measureScriptObject); + + ASSERT_EQ(measurePtr->name(), "Stack Level Too Deep Measure"); + + std::string expected_exception = + fmt::format(R"(SWIG director method error. In method 'arguments': `RecursionError('maximum recursion depth exceeded')` + +Traceback (most recent call last): + File "{0}", line 16, in arguments + s(10) + File "{0}", line 6, in s + return s(x) + File "{0}", line 6, in s + return s(x) + File "{0}", line 6, in s + return s(x) + [Previous line repeated 996 more times] +RecursionError: maximum recursion depth exceeded)", + scriptPath.generic_string()); + + openstudio::model::Model model; + try { + measurePtr->arguments(model); + ASSERT_FALSE(true) << "Expected measure arguments(model) to throw"; + } catch (std::exception& e) { + std::string error = e.what(); + EXPECT_EQ(expected_exception, error); + } +} diff --git a/python/engine/test/StackLevelTooDeepMeasure/measure.py b/python/engine/test/StackLevelTooDeepMeasure/measure.py new file mode 100644 index 00000000000..164eedb84fb --- /dev/null +++ b/python/engine/test/StackLevelTooDeepMeasure/measure.py @@ -0,0 +1,32 @@ +import typing + +import openstudio + +def s(x): + return s(x) + +class StackLevelTooDeepMeasure(openstudio.measure.ModelMeasure): + def name(self): + return "Stack Level Too Deep Measure" + + def modeler_description(self): + return "The arguments method calls an infinitely recursive function" + + def arguments(self, model: typing.Optional[openstudio.model.Model] = None): + s(10) + args = openstudio.measure.OSArgumentVector() + + return args + + def run( + self, + model: openstudio.model.Model, + runner: openstudio.measure.OSRunner, + user_arguments: openstudio.measure.OSArgumentMap, + ): + """ + define what happens when the measure is run + """ + super().run(model, runner, user_arguments) # Do **NOT** remove this line + +StackLevelTooDeepMeasure().registerWithApplication() diff --git a/python/engine/test/StackLevelTooDeepMeasure/measure.xml b/python/engine/test/StackLevelTooDeepMeasure/measure.xml new file mode 100644 index 00000000000..27ce36c5bcc --- /dev/null +++ b/python/engine/test/StackLevelTooDeepMeasure/measure.xml @@ -0,0 +1,59 @@ + + + 3.1 + stack_level_too_deep_measure + 22222222-c89b-4b93-b400-220ca060b2bb + c2e1967b-0672-4e80-84fd-9204e701f10a + 2023-11-10T14:04:04Z + 4EDA3A53 + StackLevelTooDeepMeasure + Stack Level Too Deep Measure + + The arguments method calls an infinitely recursive function + + + + + Envelope.Opaque + + + + Measure Function + Measure + string + + + Requires EnergyPlus Results + false + boolean + + + Measure Type + ModelMeasure + string + + + Uses SketchUp API + false + boolean + + + Measure Language + Python + string + + + + + + OpenStudio + 3.4.1 + 3.4.1 + + measure.py + py + script + C952AC5B + + + diff --git a/python/engine/test/WrongMethodMeasure/measure.py b/python/engine/test/WrongMethodMeasure/measure.py new file mode 100644 index 00000000000..a2f524afe3d --- /dev/null +++ b/python/engine/test/WrongMethodMeasure/measure.py @@ -0,0 +1,30 @@ +import typing + +import openstudio + + +class WrongMethodMeasure(openstudio.measure.ModelMeasure): + def name(self): + return "Wrong Method Measure" + + def modeler_description(self): + return "The arguments method calls a non existing method on the model passed as argument" + + def arguments(self, model: typing.Optional[openstudio.model.Model] = None): + model.nonExistingMethod() + args = openstudio.measure.OSArgumentVector() + + return args + + def run( + self, + model: openstudio.model.Model, + runner: openstudio.measure.OSRunner, + user_arguments: openstudio.measure.OSArgumentMap, + ): + """ + define what happens when the measure is run + """ + super().run(model, runner, user_arguments) # Do **NOT** remove this line + +WrongMethodMeasure().registerWithApplication() diff --git a/python/engine/test/WrongMethodMeasure/measure.xml b/python/engine/test/WrongMethodMeasure/measure.xml new file mode 100644 index 00000000000..47d90331a24 --- /dev/null +++ b/python/engine/test/WrongMethodMeasure/measure.xml @@ -0,0 +1,59 @@ + + + 3.1 + wrong_method_measure + 11111111-c89b-4b93-b400-110ca060b2bb + 998cdb81-addf-4fae-809d-175c6475c592 + 2023-11-10T10:37:53Z + 33A29C78 + WrongMethodMeasure + Wrong Method Measure + + The arguments method calls a non existing method on the model passed as argument + + + + + Envelope.Opaque + + + + Measure Function + Measure + string + + + Requires EnergyPlus Results + false + boolean + + + Measure Type + ModelMeasure + string + + + Measure Language + Python + string + + + Uses SketchUp API + false + boolean + + + + + + OpenStudio + 3.4.1 + 3.4.1 + + measure.py + py + script + 7DCE622C + + + diff --git a/ruby/engine/CMakeLists.txt b/ruby/engine/CMakeLists.txt index a5d3dc3e5f2..601be0496cd 100644 --- a/ruby/engine/CMakeLists.txt +++ b/ruby/engine/CMakeLists.txt @@ -287,4 +287,18 @@ elseif(UNIX) ) endif() +if(BUILD_TESTING) + set(rubyengine_test_depends + openstudio_scriptengine + openstudiolib + CONAN_PKG::fmt + ) + + set(rubyengine_test_src + test/RubyEngine_GTest.cpp + ) + + CREATE_TEST_TARGETS(rubyengine "${rubyengine_test_src}" "${rubyengine_test_depends}") +endif() + install(TARGETS rubyengine EXPORT openstudio DESTINATION ${LIB_DESTINATION_DIR} COMPONENT "CLI") diff --git a/ruby/engine/test/BadMeasure/measure.rb b/ruby/engine/test/BadMeasure/measure.rb new file mode 100644 index 00000000000..2e1681b4e8c --- /dev/null +++ b/ruby/engine/test/BadMeasure/measure.rb @@ -0,0 +1,30 @@ +class BadMeasure < OpenStudio::Measure::ModelMeasure + + def name + return "Bad Measure" + end + + def modeler_description + return "The arguments method calls another_method which does a raise ValueError" + end + + def another_method + raise "oops" + end + + def arguments(model) + another_method + args = OpenStudio::Measure::OSArgumentVector.new + + return args + end + + def run(model, runner, user_arguments) + super(model, runner, user_arguments) + + return true + end + +end + +BadMeasure.new.registerWithApplication diff --git a/ruby/engine/test/BadMeasure/measure.xml b/ruby/engine/test/BadMeasure/measure.xml new file mode 100644 index 00000000000..f28b1c73009 --- /dev/null +++ b/ruby/engine/test/BadMeasure/measure.xml @@ -0,0 +1,54 @@ + + + 3.1 + bad_measure + 812d3ebf-c89b-4b93-b400-110ca060b2bb + 4b3097c0-9874-4478-8e7f-45fa952a91eb + 2023-11-10T10:59:53Z + EB1A0C08 + BadMeasure + Bad Measure + + The arguments method calls another_method which does a raise ValueError + + + + + Envelope.Opaque + + + + Measure Function + Measure + string + + + Requires EnergyPlus Results + false + boolean + + + Measure Type + ModelMeasure + string + + + Uses SketchUp API + false + boolean + + + + + + OpenStudio + 0.11.5 + 0.11.5 + + measure.rb + rb + script + D22AC6AC + + + diff --git a/ruby/engine/test/RubyEngine_GTest.cpp b/ruby/engine/test/RubyEngine_GTest.cpp new file mode 100644 index 00000000000..a83529eeb8f --- /dev/null +++ b/ruby/engine/test/RubyEngine_GTest.cpp @@ -0,0 +1,150 @@ +/*********************************************************************************************************************** +* OpenStudio(R), Copyright (c) Alliance for Sustainable Energy, LLC. +* See also https://openstudio.net/license +***********************************************************************************************************************/ + +#include + +#include "measure/ModelMeasure.hpp" +#include "measure/OSArgument.hpp" +#include "measure/OSMeasure.hpp" +#include "model/Model.hpp" +#include "scriptengine/ScriptEngine.hpp" + +#include + +#include + +class RubyEngineFixture : public testing::Test +{ + public: + static openstudio::path getScriptPath(const std::string& classAndDirName) { + openstudio::path scriptPath = openstudio::getApplicationSourceDirectory() / fmt::format("ruby/engine/test/{}/measure.rb", classAndDirName); + OS_ASSERT(openstudio::filesystem::is_regular_file(scriptPath)); + return scriptPath; + } + + static std::string stripAddressFromErrorMessage(const std::string& error_message) { + static std::regex object_address_re("0x[[:alnum:]]*>"); + + return std::regex_replace(error_message, object_address_re, "ADDRESS>"); + } + + protected: + // initialize for each test + virtual void SetUp() override { + std::vector args; + thisEngine = std::make_unique("rubyengine", args); + + (*thisEngine)->registerType("openstudio::measure::OSMeasure *"); + (*thisEngine)->registerType("openstudio::measure::ModelMeasure *"); + (*thisEngine)->exec("OpenStudio::init_rest_of_openstudio()"); + } + // tear down after each test + virtual void TearDown() override { + // Want to ensure the engine is reset regardless of the test outcome, or it may throw a segfault + thisEngine->reset(); + } + + std::unique_ptr thisEngine; +}; + +TEST_F(RubyEngineFixture, BadMeasure) { + + const std::string classAndDirName = "BadMeasure"; + + const auto scriptPath = getScriptPath(classAndDirName); + auto measureScriptObject = (*thisEngine)->loadMeasure(scriptPath, classAndDirName); + auto* measurePtr = (*thisEngine)->getAs(measureScriptObject); + + ASSERT_EQ(measurePtr->name(), "Bad Measure"); + + std::string expected_exception = fmt::format(R"(SWIG director method error. RuntimeError: oops + +Traceback (most recent call last): +{0}:12:in `another_method' +{0}:16:in `arguments')", + scriptPath.generic_string()); + + openstudio::model::Model model; + try { + measurePtr->arguments(model); + ASSERT_FALSE(true) << "Expected measure arguments(model) to throw"; + } catch (std::exception& e) { + std::string error = e.what(); + EXPECT_EQ(expected_exception, error); + } +} + +TEST_F(RubyEngineFixture, WrongMethodMeasure) { + + const std::string classAndDirName = "WrongMethodMeasure"; + + const auto scriptPath = getScriptPath(classAndDirName); + auto measureScriptObject = (*thisEngine)->loadMeasure(scriptPath, classAndDirName); + auto* measurePtr = (*thisEngine)->getAs(measureScriptObject); + + ASSERT_EQ(measurePtr->name(), "Wrong Method Measure"); + + std::string expected_exception = + fmt::format(R"(SWIG director method error. NoMethodError: undefined method `nonExistingMethod' for # + +Traceback (most recent call last): +{0}:12:in `arguments')", + scriptPath.generic_string()); + + openstudio::model::Model model; + try { + measurePtr->arguments(model); + ASSERT_FALSE(true) << "Expected measure arguments(model) to throw"; + } catch (std::exception& e) { + std::string error = e.what(); + EXPECT_EQ(expected_exception, stripAddressFromErrorMessage(error)); + } +} + +TEST_F(RubyEngineFixture, StackLevelTooDeepMeasure) { + + const std::string classAndDirName = "StackLevelTooDeepMeasure"; + + const auto scriptPath = getScriptPath(classAndDirName); + auto measureScriptObject = (*thisEngine)->loadMeasure(scriptPath, classAndDirName); + auto* measurePtr = (*thisEngine)->getAs(measureScriptObject); + + ASSERT_EQ(measurePtr->name(), "Stack Level Too Deep Measure"); + + std::string expected_exception = fmt::format(R"(SWIG director method error. SystemStackError: stack level too deep + +Traceback (most recent call last): +{0}:16:in `arguments' +{0}:12:in `s' +{0}:12:in `s' +{0}:12:in `s' +{0}:12:in `s' +{0}:12:in `s' +{0}:12:in `s' + ... 10061 levels... +{0}:12:in `s' +{0}:12:in `s' +{0}:12:in `s' +{0}:12:in `s' +{0}:12:in `s' +{0}:12:in `s' +{0}:12:in `s' +{0}:12:in `s' +{0}:12:in `s' +{0}:12:in `s' +{0}:12:in `s' +{0}:12:in `s')", + scriptPath.generic_string()); + + openstudio::model::Model model; + try { + measurePtr->arguments(model); + ASSERT_FALSE(true) << "Expected measure arguments(model) to throw"; + } catch (std::exception& e) { + std::string error = e.what(); + EXPECT_EQ(expected_exception, stripAddressFromErrorMessage(error)); + } +} + diff --git a/ruby/engine/test/StackLevelTooDeepMeasure/measure.rb b/ruby/engine/test/StackLevelTooDeepMeasure/measure.rb new file mode 100644 index 00000000000..23c9dc5c339 --- /dev/null +++ b/ruby/engine/test/StackLevelTooDeepMeasure/measure.rb @@ -0,0 +1,30 @@ +class StackLevelTooDeepMeasure < OpenStudio::Measure::ModelMeasure + + def name + return "Stack Level Too Deep Measure" + end + + def modeler_description + return "The arguments method calls an infinitely recursive function" + end + + def s(x) + return x * s(x-1) + end + + def arguments(model) + s(10) + args = OpenStudio::Measure::OSArgumentVector.new + + return args + end + + def run(model, runner, user_arguments) + super(model, runner, user_arguments) + + return true + end + +end + +StackLevelTooDeepMeasure.new.registerWithApplication diff --git a/ruby/engine/test/StackLevelTooDeepMeasure/measure.xml b/ruby/engine/test/StackLevelTooDeepMeasure/measure.xml new file mode 100644 index 00000000000..7fe301f57e8 --- /dev/null +++ b/ruby/engine/test/StackLevelTooDeepMeasure/measure.xml @@ -0,0 +1,59 @@ + + + 3.1 + stack_level_too_deep_measure + 22222222-c89b-4b93-b400-220ca060b2bb + b9722f6a-e289-4915-8297-6dad9bf08556 + 2023-11-10T13:43:11Z + 5872977E + StackLevelTooDeepMeasure + Stack Level Too Deep Measure + + The arguments method calls an infinitely recursive function + + + + + Envelope.Opaque + + + + Measure Function + Measure + string + + + Requires EnergyPlus Results + false + boolean + + + Measure Type + ModelMeasure + string + + + Uses SketchUp API + false + boolean + + + Measure Language + Ruby + string + + + + + + OpenStudio + 3.4.1 + 3.4.1 + + measure.rb + rb + script + 9AB518E3 + + + diff --git a/ruby/engine/test/WrongMethodMeasure/measure.rb b/ruby/engine/test/WrongMethodMeasure/measure.rb new file mode 100644 index 00000000000..6f9eb26c22a --- /dev/null +++ b/ruby/engine/test/WrongMethodMeasure/measure.rb @@ -0,0 +1,26 @@ +class WrongMethodMeasure < OpenStudio::Measure::ModelMeasure + + def name + return "Wrong Method Measure" + end + + def modeler_description + return "The arguments method calls a non existing method on the model passed as argument" + end + + def arguments(model) + model.nonExistingMethod() + args = OpenStudio::Measure::OSArgumentVector.new + + return args + end + + def run(model, runner, user_arguments) + super(model, runner, user_arguments) + + return true + end + +end + +WrongMethodMeasure.new.registerWithApplication diff --git a/ruby/engine/test/WrongMethodMeasure/measure.xml b/ruby/engine/test/WrongMethodMeasure/measure.xml new file mode 100644 index 00000000000..812f4c995d2 --- /dev/null +++ b/ruby/engine/test/WrongMethodMeasure/measure.xml @@ -0,0 +1,59 @@ + + + 3.1 + wrong_method_measure + 11111111-c89b-4b93-b400-110ca060b2bb + 8c8f5697-b6e0-4ae0-90f9-cace0f872d41 + 2023-11-10T11:02:27Z + 5872977E + WrongMethodMeasure + Wrong Method Measure + + The arguments method calls a non existing method on the model passed as argument + + + + + Envelope.Opaque + + + + Measure Function + Measure + string + + + Requires EnergyPlus Results + false + boolean + + + Measure Type + ModelMeasure + string + + + Uses SketchUp API + false + boolean + + + Measure Language + Ruby + string + + + + + + OpenStudio + 3.4.1 + 3.4.1 + + measure.rb + rb + script + BB98326D + + + diff --git a/src/measure/Measure.i b/src/measure/Measure.i index ea239940b8a..a3cedd922cd 100644 --- a/src/measure/Measure.i +++ b/src/measure/Measure.i @@ -96,6 +96,71 @@ }; #if defined SWIGRUBY + %feature("director:except") { + // Mimic openstudio::evalString for a possible approach to reporting more info or a callstack. + VALUE errinfo = rb_errinfo(); + VALUE exception_class = rb_obj_class(errinfo); + VALUE classNameValue = rb_class_name(exception_class); + std::string className(StringValuePtr(classNameValue)); + + VALUE errstr = rb_obj_as_string(errinfo); + std::string errMessage(StringValuePtr(errstr)); + + std::string totalErr = className + ": " + errMessage; + + // Generally speaking, the backtrace is there, but not for the case where it's a stack too deep error + std::string loc; + const ID ID_backtrace = rb_intern_const("backtrace"); + if (exception_class != rb_eSysStackError && rb_respond_to(errinfo, ID_backtrace)) { + /*volatile*/ VALUE backtrace; + if (!NIL_P(backtrace = rb_funcall(errinfo, ID_backtrace, 0))) { + VALUE backtracejoin = rb_ary_join(backtrace, rb_str_new2("\n")); + const std::string btlines = StringValuePtr(backtracejoin); + if (!btlines.empty()) { + loc += "\nTraceback (most recent call last):\n" + btlines; + } + } + } + if (loc.empty()) { + // In case we couldn't produce the backtrace (SystemStackError: stack level too deep), fall back on this: + // I just **cannot** figure out a way to get the error location in C, without calling $@.to_s. Seems nothing is available here + // $@ is an array, seems to be already ordered from most recent to older + int locationError; + VALUE btArray = rb_eval_string_protect("$@", &locationError); // "$@.reverse" + if (locationError == 0) { + // Get the backing C array of the ruby array + VALUE* elements = RARRAY_PTR(btArray); + long arrayLen = RARRAY_LEN(btArray); + const long back_trace_limit = 20; + loc += "\nTraceback (most recent call last):"; + + if (arrayLen > back_trace_limit) { + long omit = arrayLen - back_trace_limit; + + const long initial_back_trace_limit_when_too_long = 8; + for (long c = arrayLen - 1 ; c > arrayLen - initial_back_trace_limit_when_too_long; --c) { + VALUE entry = elements[c]; + std::string backtrace_line = RSTRING_PTR(entry); + loc += "\n" + std::string(backtrace_line); + } + loc += "\n\t... " + std::to_string(omit) + " levels..."; + + arrayLen = back_trace_limit - initial_back_trace_limit_when_too_long; + } + for (long c = 0; c < arrayLen; ++c) { + VALUE entry = elements[c]; + std::string backtrace_line = RSTRING_PTR(entry); + loc += "\n" + std::string(backtrace_line); + } + + } else { + loc = "Failed to get VM location"; + } + } + + totalErr += "\n" + loc; + throw Swig::DirectorMethodException(totalErr.c_str()); + } %ignore OSMeasureInfoGetter; @@ -107,4 +172,76 @@ #endif +#if defined SWIGPYTHON + %feature("director:except") { + + if ($error != nullptr) { + + PyObject *exc_type = nullptr; + PyObject *exc_value = nullptr; + PyObject *exc_tb = nullptr; + PyErr_Fetch(&exc_type, &exc_value, &exc_tb); + PyErr_NormalizeException(&exc_type, &exc_value, &exc_tb); + PyObject *str_exc_value = PyObject_Repr(exc_value); // Now a unicode object + PyObject *pyStr2 = PyUnicode_AsEncodedString(str_exc_value, "utf-8", "Error ~"); + Py_DECREF(str_exc_value); + char *strExcValue = PyBytes_AsString(pyStr2); // NOLINT(hicpp-signed-bitwise) + Py_DECREF(pyStr2); + + std::string err_msg = "In method '$symname': `" + std::string{strExcValue} + "`"; + + // See if we can get a full traceback. + // Calls into python, and does the same as capturing the exception in `e` + // then `print(traceback.format_exception(e.type, e.value, e.tb))` + PyObject *pModuleName = PyUnicode_DecodeFSDefault("traceback"); + PyObject *pyth_module = PyImport_Import(pModuleName); + Py_DECREF(pModuleName); + + if (pyth_module == nullptr) { + err_msg += "\nCannot find 'traceback' module, this should not happen"; + throw Swig::DirectorMethodException(err_msg.c_str()); + } + + PyObject *pyth_func = PyObject_GetAttrString(pyth_module, "format_exception"); + Py_DECREF(pyth_module); // PyImport_Import returns a new reference, decrement it + + if (pyth_func || PyCallable_Check(pyth_func)) { + + PyObject *pyth_val = PyObject_CallFunction(pyth_func, "OOO", exc_type, exc_value, exc_tb); + + // traceback.format_exception returns a list, so iterate on that + if (!pyth_val || !PyList_Check(pyth_val)) { // NOLINT(hicpp-signed-bitwise) + err_msg += "\ntraceback.format_exception did not return a list."; + } else { + Py_ssize_t numVals = PyList_Size(pyth_val); + if (numVals == 0) { + err_msg += "\nNo traceback available"; + } else { + // err_msg += "\nPython traceback follows:\n```"; + err_msg += '\n'; + for (Py_ssize_t itemNum = 0; itemNum < numVals; itemNum++) { + PyObject *item = PyList_GetItem(pyth_val, itemNum); + if (PyUnicode_Check(item)) { // NOLINT(hicpp-signed-bitwise) -- something inside Python code causes warning + std::string traceback_line = PyUnicode_AsUTF8(item); + if (!traceback_line.empty() && traceback_line[traceback_line.length() - 1] == '\n') { + traceback_line.erase(traceback_line.length() - 1); + } + err_msg += "\n" + traceback_line; + } + // PyList_GetItem returns a borrowed reference, do not decrement + } + + // err_msg += "\n```"; + } + } + + // PyList_Size returns a borrowed reference, do not decrement + Py_DECREF(pyth_val); // PyObject_CallFunction returns new reference, decrement + } + Py_DECREF(pyth_func); // PyObject_GetAttrString returns a new reference, decrement it + throw Swig::DirectorMethodException(err_msg.c_str()); + } + } +#endif + #endif // MEASURE_I