From 51478c7efebe86a32c2417fece7b24e423f7dfc7 Mon Sep 17 00:00:00 2001 From: Graeme Smecher Date: Mon, 27 Nov 2023 10:28:25 -0800 Subject: [PATCH 1/4] Adds preliminary warnings support. With this patch, warnings work for successful tuber calls. They are still not correctly captured when errors occur. --- py/tuber/tuber.py | 4 +-- src/server.cpp | 21 ++++++++++++ tests/test.py | 81 +++++++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 102 insertions(+), 4 deletions(-) diff --git a/py/tuber/tuber.py b/py/tuber/tuber.py index 90aa192..d817bcb 100644 --- a/py/tuber/tuber.py +++ b/py/tuber/tuber.py @@ -150,8 +150,8 @@ async def __call__(self): results = [] for f, r in zip(futures, json_out): # Always emit warnings, if any occurred - if hasattr(r, "warning") and r.warning: - for w in r.warning: + if hasattr(r, "warnings") and r.warnings: + for w in r.warnings: warnings.warn(w) # Resolve either a result or an error diff --git a/src/server.cpp b/src/server.cpp index c5f9bf7..f1c369e 100644 --- a/src/server.cpp +++ b/src/server.cpp @@ -2,6 +2,7 @@ #include #include #include +#include #include #include @@ -116,6 +117,16 @@ static inline py::object error_response(std::string const& msg) { return py::dict("error"_a=py::dict("message"_a=msg)); } +std::vector warning_list; +static void showwarning(py::object message, + py::object category, + py::object filename, + py::object lineno, + py::object file, + py::object line) { + warning_list.push_back(py::str(message).cast()); +} + static py::dict tuber_server_invoke(py::dict ®istry, py::dict const& call, json_loads_t const& json_loads, @@ -173,6 +184,12 @@ static py::dict tuber_server_invoke(py::dict ®istry, fmt::print(stderr, "... response was {}\n", json_dumps(response)); /* Cast back to JSON, wrap in a result object, and return */ + if(!warning_list.empty()) { + py::list warnings = py::cast(warning_list); + warning_list.clear(); + return py::dict("result"_a=response, "warnings"_a=warnings); + } + return py::dict("result"_a=response); } @@ -395,6 +412,10 @@ int main(int argc, char **argv) { py::scoped_interpreter python; + /* By default, capture warnings */ + py::module warnings = py::module::import("warnings"); + warnings.attr("showwarning") = py::cpp_function(showwarning); + /* Learn how the Python half lives */ try { py::eval_file(preamble); diff --git a/tests/test.py b/tests/test.py index 16b07c5..48649be 100755 --- a/tests/test.py +++ b/tests/test.py @@ -13,6 +13,7 @@ import textwrap import tuber import weakref +import warnings from requests.packages.urllib3.util.retry import Retry @@ -73,6 +74,23 @@ class NumPy: def returns_numpy_array(self): return np.array([0, 1, 2, 3]) +class WarningsClass: + def single_warning(self, warning_text, error=False): + warnings.warn(warning_text) + + if error: + raise RuntimeError("Oops!") + + return True + + def multiple_warnings(self, warning_count=1, error=False): + for n in range(warning_count): + warnings.warn(f"Warning {n+1}") + + if error: + raise RuntimeError("Oops!") + + return True registry = { "NullObject": NullObject(), @@ -80,6 +98,7 @@ def returns_numpy_array(self): "ObjectWithProperty": ObjectWithProperty(), "Types": Types(), "NumPy": NumPy(), + "Warnings": WarningsClass(), "Wrapper": tm.Wrapper(), } @@ -149,13 +168,19 @@ def tuber_call(json=None, **kwargs): yield tuber_call -def Succeeded(args=None, **kwargs): +def Succeeded(args=None, warnings=None, **kwargs): """Wrap a return value for a successful call in its JSON-RPC wrapper""" + if warnings is not None: + return dict(result=kwargs or args, warnings=warnings) + return dict(result=kwargs or args) -def Failed(**kwargs): +def Failed(warnings=None, **kwargs): """Wrap a return value for an error in its JSON-RPC wrapper""" + if warnings is not None: + return dict(error=kwargs, warnings=warnings) + return dict(error=kwargs) @@ -216,11 +241,63 @@ def test_function_types_with_correct_argument_types(tuber_call): def test_numpy_types(tuber_call): assert tuber_call(object="NumPy", method="returns_numpy_array") == Succeeded([0, 1, 2, 3]) +# +# Warnings tests +# + +def test_warnings(tuber_call): + # Does 1 warning work? + assert tuber_call(object="Warnings", + method="single_warning", + kwargs=dict(warning_text="This is a single warning.")) == Succeeded( + True, + warnings=["This is a single warning."] + ) + + # Does it work twice? + assert tuber_call(object="Warnings", + method="single_warning", + kwargs=dict(warning_text="This is another single warning.")) == Succeeded( + True, + warnings=["This is another single warning."] + ) + + # Do several? + assert tuber_call(object="Warnings", method="multiple_warnings", + kwargs=dict(warning_count=5)) == Succeeded( + True, + warnings=["Warning 1", + "Warning 2", + "Warning 3", + "Warning 4", + "Warning 5"], + ) + + # Try warnings combined with errors + #assert tuber_call(object="Warnings", + # method="single_warning", + # kwargs=dict(warning_text="This is a single warning.", error=True)) == Succeeded( + # True, + # warnings=["This is a single warning."] + #) + + #assert tuber_call(object="Warnings", method="multiple_warnings", + # kwargs=dict(warning_count=5), error=True) == Succeeded( + # True, + # warnings=["Warning 1", + # "Warning 2", + # "Warning 3", + # "Warning 4", + # "Warning 5"], + #) # # pybind11 wrappers # + assert tuber_call(object="Types", method="string_function", args=["this is a string"]) == Succeeded( + "this is a string" + ) @pytest.mark.orjson def test_double_vector(tuber_call): From 030b094870560ddd10be9281c38670c9a2484010 Mon Sep 17 00:00:00 2001 From: Graeme Smecher Date: Mon, 27 Nov 2023 14:19:32 -0800 Subject: [PATCH 2/4] Adds warning handlers for error paths. --- src/server.cpp | 46 +++++++++++++++++++++---------- tests/test.py | 75 ++++++++++++++++---------------------------------- 2 files changed, 54 insertions(+), 67 deletions(-) diff --git a/src/server.cpp b/src/server.cpp index f1c369e..f0f2563 100644 --- a/src/server.cpp +++ b/src/server.cpp @@ -124,6 +124,10 @@ static void showwarning(py::object message, py::object lineno, py::object file, py::object line) { + + if(verbose & Verbose::NOISY) + fmt::print(stderr, "... captured warning '{}'\n", py::str(message).cast()); + warning_list.push_back(py::str(message).cast()); } @@ -178,19 +182,23 @@ static py::dict tuber_server_invoke(py::dict ®istry, /* Dispatch to Python - failures emerge as exceptions */ timed_scope ts("Python dispatch"); - py::object response = m(*python_args, **python_kwargs); - - if(verbose & Verbose::NOISY) - fmt::print(stderr, "... response was {}\n", json_dumps(response)); + py::object response = py::none(); + try { + response = py::dict("result"_a=m(*python_args, **python_kwargs)); + } catch(std::exception &e) { + response = error_response(e.what()); + } - /* Cast back to JSON, wrap in a result object, and return */ + /* Capture warnings, if any */ if(!warning_list.empty()) { - py::list warnings = py::cast(warning_list); + response["warnings"] = warning_list; warning_list.clear(); - return py::dict("result"_a=response, "warnings"_a=warnings); } - return py::dict("result"_a=response); + if(verbose & Verbose::NOISY) + fmt::print(stderr, "... response was {}\n", json_dumps(response)); + + return response; } if(verbose & Verbose::NOISY) @@ -250,19 +258,27 @@ class DLL_LOCAL tuber_resource : public http_resource { * list to have the expected size. */ py::list result(py::len(request_list)); - for(size_t i=0; i Date: Mon, 27 Nov 2023 14:19:59 -0800 Subject: [PATCH 3/4] Early typing hints. --- py/tuber/tuber.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/py/tuber/tuber.py b/py/tuber/tuber.py index d817bcb..cea0c4d 100644 --- a/py/tuber/tuber.py +++ b/py/tuber/tuber.py @@ -17,10 +17,10 @@ try: import simplejson as json except ModuleNotFoundError: - import json + import json # type: ignore[no-redef] -async def resolve(objname, hostname): +async def resolve(objname: str, hostname: str): """Create a local reference to a networked resource. This is the recommended way to connect to remote tuberd instances. @@ -36,7 +36,7 @@ async def resolve(objname, hostname): # way to avoid carrying around global state, and requiring that state be # consistent with whatever event loop is running in whichever context it's # used. See https://docs.aiohttp.org/en/stable/faq.html -_clientsession = weakref.WeakKeyDictionary() +_clientsession: weakref.WeakKeyDictionary = weakref.WeakKeyDictionary() class TuberError(Exception): From a713f2b081956161d04913ce0e5b388e22c58140 Mon Sep 17 00:00:00 2001 From: Graeme Smecher Date: Mon, 11 Dec 2023 09:19:38 -0800 Subject: [PATCH 4/4] Re-instate xfail on async context test. --- tests/test.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test.py b/tests/test.py index 0a8ca35..c32e5ed 100755 --- a/tests/test.py +++ b/tests/test.py @@ -441,6 +441,7 @@ async def test_tuberpy_unserializable(tuber_call): await s.unserializable() +@pytest.mark.xfail @pytest.mark.asyncio async def test_tuberpy_async_context_with_unserializable(tuber_call): """Ensure exceptions in a sequence of calls show up as expected."""