New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Fix #8155 - Improve API type checking and error handling, and produce Python traceback for Plugin #8181
Conversation
…ly copied to the Products/pyenergyplus dir on rebuild temporary*: this will ALWAYS run, even if no changes were done. But these are small .py files and no targets will be recompiled because of it, so perhaps it's acceptable as is
# TODO: temporary "hack" to depend on a specific file that doesn't exist to force this to run | ||
# ideally the whole process would be revamped | ||
DEPENDS __ALWAYSRUNME |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@Myoldmopar FYI. This is unrelated to #8155 but we've talked about it.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
A nice workaround for right now!
PYTHONWRAP_API PyObject * EP_Wrap_PyObject_CallFunction3Args(PyObject *callable, const char *format, PyObject * arg1, PyObject * arg2, PyObject * arg3) {return PyObject_CallFunction(callable, format, arg1, arg2, arg3);} | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I add to register this one so I could call a function with 3 args and a format string.
PyObject_CallFunction
is actually variadic, something the wrapping is losing. I couldn't really figure out how to forward these variable number of args, so I just added a wrap for my specific number of args that I needed.
Note the ...
in the function signature:
PyObject* PyObject_CallFunction(PyObject *callable, const char *format, ...)
https://docs.python.org/3/c-api/object.html#c.PyObject_CallFunction
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Understood, I don't imagine we'll need any additional instances of CallFunction, so this is a suitable addition.
@@ -70,6 +70,7 @@ extern "C" { | |||
PYTHONWRAP_API int EP_Wrap_PyRun_SimpleString(const char * c) {return PyRun_SimpleString(c);} | |||
PYTHONWRAP_API int EP_Wrap_Py_FinalizeEx() {return Py_FinalizeEx();} | |||
PYTHONWRAP_API void EP_Wrap_PyErr_Fetch(PyObject **a, PyObject **b, PyObject **c) {PyErr_Fetch(a, b, c);} | |||
PYTHONWRAP_API void EP_Wrap_PyErr_NormalizeException(PyObject **a, PyObject **b, PyObject **c) {PyErr_NormalizeException(a, b, c);} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is needed as I got a few cases where without it I couldn't produce a traceback.
if isinstance(variable_name, str): | ||
variable_name = variable_name.encode('utf-8') | ||
elif not isinstance(variable_name, bytes): | ||
raise EnergyPlusException( | ||
"`request_variable` expects `component_type` as a `str` or UTF-8 encoded `bytes`, not " | ||
"'{}'".format(variable_name)) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Typical Type checking for a Union[str, bytes]
argument
return self.api.getActuatorHandle(component_type, control_type, actuator_key) | ||
|
||
def get_variable_value(self, variable_handle: int) -> RealEP: | ||
def get_variable_value(self, variable_handle: int) -> float: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I changed the type hints for the entire file to use float
and not RealEP
as it should be.
if not is_number(variable_handle): | ||
raise EnergyPlusException( | ||
"`get_variable_value` expects `variable_handle` as an `int`, not " | ||
"'{}'".format(variable_handle)) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Typical type checking for an int
argument
if not is_number(actuator_value): | ||
raise EnergyPlusException( | ||
"`set_actuator_value` expects `actuator_value` as a `float`, not " | ||
"'{}'".format(actuator_value)) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Typical type checking for a float
argument
import pytest | ||
|
||
from pyenergyplus.api import EnergyPlusAPI | ||
from pyenergyplus.common import EnergyPlusException | ||
|
||
api = EnergyPlusAPI() | ||
|
||
class TestPythonAPITypes(): | ||
""" | ||
py.test on EnergyPlusAPI.exchange functions to see type checking in action | ||
""" | ||
|
||
def test_get_actuator_handle(self): | ||
|
||
assert (-1 == api.exchange.get_actuator_handle( | ||
u"Weather Data", u"Outdoor Dew Point", u"Environment")) | ||
|
||
# Wrong number of arguments | ||
with pytest.raises(TypeError) as e: | ||
api.exchange.get_actuator_handle( | ||
u"Weather Data", u"Outdoor Dew Point") | ||
assert ("get_actuator_handle() missing 1 required positional argument: 'actuator_key'" in str(e.value)) | ||
|
||
with pytest.raises(EnergyPlusException) as e: | ||
api.exchange.get_actuator_handle( | ||
1, u"Outdoor Dew Point", u"Environment") | ||
assert ("`get_actuator_handle` expects `component_type` as a `str` or UTF-8 encoded `bytes`, not '1'" in str(e.value)) | ||
|
||
with pytest.raises(EnergyPlusException) as e: | ||
api.exchange.get_actuator_handle( | ||
u"Weather Data", 2, u"Environment") | ||
assert ("`get_actuator_handle` expects `control_type` as a `str` or UTF-8 encoded `bytes`, not '2'" in str(e.value)) | ||
|
||
with pytest.raises(EnergyPlusException) as e: | ||
api.exchange.get_actuator_handle( | ||
u"Weather Data", u"Outdoor Dew Point", 3) | ||
assert ("`get_actuator_handle` expects `actuator_key` as a `str` or UTF-8 encoded `bytes`, not '3'" in str(e.value)) | ||
|
||
|
||
def test_get_variable_value(self): | ||
assert(0.0 == api.exchange.get_variable_value(-1)) | ||
|
||
with pytest.raises(TypeError) as e: | ||
api.exchange.get_variable_value() | ||
assert ("get_variable_value() missing 1 required positional argument: 'variable_handle'" in str(e.value)) | ||
|
||
with pytest.raises(EnergyPlusException) as e: | ||
api.exchange.get_variable_value(u"foo") | ||
assert ("`get_variable_value` expects `variable_handle` as a `str` or UTF-8 encoded `bytes`, not 'foo'" in str(e.value)) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
New pytest file to show behavior for a couple of functions each.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nice!
|
||
// 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))` | ||
PyObjectWrap pModuleName = (*EP_PyUnicode_DecodeFSDefault)("traceback"); | ||
PyObjectWrap pyth_module = (*EP_PyImport_Import)(pModuleName); | ||
(*EP_Py_DECREF)(pModuleName); | ||
|
||
if (pyth_module == NULL) { | ||
EnergyPlus::ShowFatalError("Cannot find 'traceback' module in reportPythonError(), this is weird"); | ||
return; | ||
} | ||
|
||
PyObjectWrap pyth_func = (*EP_PyObject_GetAttrString)(pyth_module, "format_exception"); | ||
(*EP_Py_DECREF)(pyth_module); // PyImport_Import returns a new reference, decrement it | ||
|
||
if (pyth_func || (*EP_PyCallable_Check)(pyth_func)) { | ||
|
||
PyObjectWrap pyth_val = (*EP_PyObject_CallFunction3Args)(pyth_func, "OOO", exc_type, exc_value, exc_tb); | ||
|
||
// traceback.format_exception returns a list, so iterate on that | ||
if (!pyth_val || !(*EP_PyList_Check)(pyth_val)) { // NOLINT(hicpp-signed-bitwise) | ||
EnergyPlus::ShowFatalError("In reportPythonError(), traceback.format_exception did not return a list."); | ||
} | ||
|
||
unsigned long numVals = (*EP_PyList_Size)(pyth_val); | ||
if (numVals == 0) { | ||
EnergyPlus::ShowFatalError("No traceback available"); | ||
return; | ||
} | ||
|
||
EnergyPlus::ShowContinueError("Python traceback follows: "); | ||
|
||
EnergyPlus::ShowContinueError("```"); | ||
|
||
for (unsigned long itemNum = 0; itemNum < numVals; itemNum++) { | ||
PyObjectWrap item = (*EP_PyList_GetItem)(pyth_val, itemNum); | ||
if ((*EP_PyUnicode_Check)(item)) { // NOLINT(hicpp-signed-bitwise) -- something inside Python code causes warning | ||
std::string traceback_line = (*EP_PyUnicode_AsUTF8)(item); | ||
if (!traceback_line.empty() && traceback_line[traceback_line.length()-1] == '\n') { | ||
traceback_line.erase(traceback_line.length()-1); | ||
} | ||
EnergyPlus::ShowContinueError(" >>> " + traceback_line); | ||
} | ||
// PyList_GetItem returns a borrowed reference, do not decrement | ||
} | ||
|
||
EnergyPlus::ShowContinueError("```"); | ||
|
||
// PyList_Size returns a borrowed reference, do not decrement | ||
(*EP_Py_DECREF)(pyth_val); // PyObject_CallFunction returns new reference, decrement | ||
} | ||
(*EP_Py_DECREF)(pyth_func); // PyObject_GetAttrString returns a new reference, decrement it |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
New call from C to Python to get an actual traceback, then decode every line in said traceback and output it to the eplusout.err
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is very cool.
Mac bot is reporting that it doesn't have pytest installed :/
|
Can you just use the raw |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The only thing that I think really needs to change is pytest -> unittest. Otherwise this is great!
# TODO: temporary "hack" to depend on a specific file that doesn't exist to force this to run | ||
# ideally the whole process would be revamped | ||
DEPENDS __ALWAYSRUNME |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
A nice workaround for right now!
|
||
// 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))` | ||
PyObjectWrap pModuleName = (*EP_PyUnicode_DecodeFSDefault)("traceback"); | ||
PyObjectWrap pyth_module = (*EP_PyImport_Import)(pModuleName); | ||
(*EP_Py_DECREF)(pModuleName); | ||
|
||
if (pyth_module == NULL) { | ||
EnergyPlus::ShowFatalError("Cannot find 'traceback' module in reportPythonError(), this is weird"); | ||
return; | ||
} | ||
|
||
PyObjectWrap pyth_func = (*EP_PyObject_GetAttrString)(pyth_module, "format_exception"); | ||
(*EP_Py_DECREF)(pyth_module); // PyImport_Import returns a new reference, decrement it | ||
|
||
if (pyth_func || (*EP_PyCallable_Check)(pyth_func)) { | ||
|
||
PyObjectWrap pyth_val = (*EP_PyObject_CallFunction3Args)(pyth_func, "OOO", exc_type, exc_value, exc_tb); | ||
|
||
// traceback.format_exception returns a list, so iterate on that | ||
if (!pyth_val || !(*EP_PyList_Check)(pyth_val)) { // NOLINT(hicpp-signed-bitwise) | ||
EnergyPlus::ShowFatalError("In reportPythonError(), traceback.format_exception did not return a list."); | ||
} | ||
|
||
unsigned long numVals = (*EP_PyList_Size)(pyth_val); | ||
if (numVals == 0) { | ||
EnergyPlus::ShowFatalError("No traceback available"); | ||
return; | ||
} | ||
|
||
EnergyPlus::ShowContinueError("Python traceback follows: "); | ||
|
||
EnergyPlus::ShowContinueError("```"); | ||
|
||
for (unsigned long itemNum = 0; itemNum < numVals; itemNum++) { | ||
PyObjectWrap item = (*EP_PyList_GetItem)(pyth_val, itemNum); | ||
if ((*EP_PyUnicode_Check)(item)) { // NOLINT(hicpp-signed-bitwise) -- something inside Python code causes warning | ||
std::string traceback_line = (*EP_PyUnicode_AsUTF8)(item); | ||
if (!traceback_line.empty() && traceback_line[traceback_line.length()-1] == '\n') { | ||
traceback_line.erase(traceback_line.length()-1); | ||
} | ||
EnergyPlus::ShowContinueError(" >>> " + traceback_line); | ||
} | ||
// PyList_GetItem returns a borrowed reference, do not decrement | ||
} | ||
|
||
EnergyPlus::ShowContinueError("```"); | ||
|
||
// PyList_Size returns a borrowed reference, do not decrement | ||
(*EP_Py_DECREF)(pyth_val); // PyObject_CallFunction returns new reference, decrement | ||
} | ||
(*EP_Py_DECREF)(pyth_func); // PyObject_GetAttrString returns a new reference, decrement it |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is very cool.
PYTHONWRAP_API PyObject * EP_Wrap_PyObject_CallFunction3Args(PyObject *callable, const char *format, PyObject * arg1, PyObject * arg2, PyObject * arg3) {return PyObject_CallFunction(callable, format, arg1, arg2, arg3);} | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Understood, I don't imagine we'll need any additional instances of CallFunction, so this is a suitable addition.
import pytest | ||
|
||
from pyenergyplus.api import EnergyPlusAPI | ||
from pyenergyplus.common import EnergyPlusException | ||
|
||
api = EnergyPlusAPI() | ||
|
||
class TestPythonAPITypes(): | ||
""" | ||
py.test on EnergyPlusAPI.exchange functions to see type checking in action | ||
""" | ||
|
||
def test_get_actuator_handle(self): | ||
|
||
assert (-1 == api.exchange.get_actuator_handle( | ||
u"Weather Data", u"Outdoor Dew Point", u"Environment")) | ||
|
||
# Wrong number of arguments | ||
with pytest.raises(TypeError) as e: | ||
api.exchange.get_actuator_handle( | ||
u"Weather Data", u"Outdoor Dew Point") | ||
assert ("get_actuator_handle() missing 1 required positional argument: 'actuator_key'" in str(e.value)) | ||
|
||
with pytest.raises(EnergyPlusException) as e: | ||
api.exchange.get_actuator_handle( | ||
1, u"Outdoor Dew Point", u"Environment") | ||
assert ("`get_actuator_handle` expects `component_type` as a `str` or UTF-8 encoded `bytes`, not '1'" in str(e.value)) | ||
|
||
with pytest.raises(EnergyPlusException) as e: | ||
api.exchange.get_actuator_handle( | ||
u"Weather Data", 2, u"Environment") | ||
assert ("`get_actuator_handle` expects `control_type` as a `str` or UTF-8 encoded `bytes`, not '2'" in str(e.value)) | ||
|
||
with pytest.raises(EnergyPlusException) as e: | ||
api.exchange.get_actuator_handle( | ||
u"Weather Data", u"Outdoor Dew Point", 3) | ||
assert ("`get_actuator_handle` expects `actuator_key` as a `str` or UTF-8 encoded `bytes`, not '3'" in str(e.value)) | ||
|
||
|
||
def test_get_variable_value(self): | ||
assert(0.0 == api.exchange.get_variable_value(-1)) | ||
|
||
with pytest.raises(TypeError) as e: | ||
api.exchange.get_variable_value() | ||
assert ("get_variable_value() missing 1 required positional argument: 'variable_handle'" in str(e.value)) | ||
|
||
with pytest.raises(EnergyPlusException) as e: | ||
api.exchange.get_variable_value(u"foo") | ||
assert ("`get_variable_value` expects `variable_handle` as a `str` or UTF-8 encoded `bytes`, not 'foo'" in str(e.value)) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nice!
This is a really great cleanup on the Python type handling, and the traceback is super nice. I understand the issue with the variadics, and while I don't like the intermediate Python "wrapper", it was very important to create a lazy link to the Python DLL. I can go into more detail about that whole process some other time. In any case, this looks good outside of the pytest issue. How do you feel about making the change to just raw unittest? |
I'm nor too familiar with unittest, but it shoud be straigjtforward to migrate since it's a small test file. I'll do that then, I know how much you don't want to add python module dependencies :) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Everything looks great now! This is a big set of fixes, thanks!
@@ -0,0 +1,53 @@ | |||
import unittest |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
👍
CI is very happy now with the latest test. I'm going to pull in develop locally and make sure it's all good since develop has moved a lot today, but I anticipate it is ready to go. Thanks! |
Everything passes locally, this is great. Thanks @jmarrec ! |
@Myoldmopar thanks for the review. Should we do something about the duplicate actuators in some the EMS & PythonPlugin EMS clones that are now issuing a warning in the eplusout.err? |
Pull request overview
I added a pytest file (and running that pytest as a cmake add_test) to show the error handling API side.
And here is a sample
eplusout.err
output with traceback:Pull Request Author
Add to this list or remove from it as applicable. This is a simple templated set of guidelines.
Reviewer
This will not be exhaustively relevant to every PR.