From edcdbd97a2d80fa50f199d003c2109f77d6cbf50 Mon Sep 17 00:00:00 2001 From: Ghislain Fourny Date: Wed, 11 Nov 2026 15:17:36 +0100 Subject: [PATCH 1/3] Display concise Java query errors in notebooks --- README.md | 13 +++++++ src/jsoniq/__init__.py | 3 +- src/jsoniq/errors.py | 40 ++++++++++++++++++++ src/jsoniq/sequence.py | 18 ++++++++- src/jsoniq/session.py | 3 ++ tests/test_notebook_errors.py | 71 +++++++++++++++++++++++++++++++++++ 6 files changed, 146 insertions(+), 2 deletions(-) create mode 100644 src/jsoniq/errors.py create mode 100644 tests/test_notebook_errors.py diff --git a/README.md b/README.md index 9fd821e..913dad1 100644 --- a/README.md +++ b/README.md @@ -543,3 +543,16 @@ rumble.getRumbleConf().set("debug.showErrorInfo", True) - Ability to write back a sequence of items to local disk, HDFS, S3... in various formats (JSON, CSV, Parquet...). - Automatically declare external variables bound as DataFrames to improve userfriendliness. - Simplified the function names to make them more intuitive (json(), items(), df(), rdd(), etc). + +### Error display in notebooks + +Java errors raised by `rumble.jsoniq(...)`, `rumble.xquery(...)`, and sequence +accessors such as `.df()`, `.pdf()`, and `.serialize()` display just the Java +error message in Jupyter, without a Python traceback. No notebook setup is needed. +The failed cell still stops, and Python code can catch `jsoniq.RumbleError` +(which also remains compatible with `Py4JJavaError` handlers). + +Set `rumble.getRumbleConf().set("debug.showErrorInfo", True)` to retain the +original exception and full traceback. Outside IPython, normal Python traceback +reporting still applies. Errors raised later by operations on returned Spark +DataFrames or RDDs use Spark's usual error reporting. diff --git a/src/jsoniq/__init__.py b/src/jsoniq/__init__.py index 323dbeb..bddfc9f 100644 --- a/src/jsoniq/__init__.py +++ b/src/jsoniq/__init__.py @@ -1,3 +1,4 @@ +from jsoniq.errors import RumbleError from jsoniq.session import RumbleSession -__all__ = ["RumbleSession"] +__all__ = ["RumbleSession", "RumbleError"] diff --git a/src/jsoniq/errors.py b/src/jsoniq/errors.py new file mode 100644 index 0000000..ac7250c --- /dev/null +++ b/src/jsoniq/errors.py @@ -0,0 +1,40 @@ +"""Notebook-friendly Java errors that retain normal exception semantics.""" + +from functools import wraps + +from py4j.protocol import Py4JJavaError + + +class RumbleError(Py4JJavaError): + """A Java query error displayed without a traceback by IPython. + + Remains a Py4JJavaError for compatibility with existing exception handlers. + Ordinary Python execution still reports a traceback. + """ + + def __init__(self, original): + super().__init__(original.args[0], original.java_exception) + self.message = original.java_exception.getMessage() + + def __str__(self): + return self.message or self.java_exception.toString() + + def _render_traceback_(self): + return str(self).splitlines() + + +def notebook_errors(method): + """Translate Java errors unless full debugging output was requested.""" + @wraps(method) + def wrapped(self, *args, **kwargs): + try: + return method(self, *args, **kwargs) + except RumbleError: + raise + except Py4JJavaError as error: + session = self.__dict__.get("_rumblesession", self) + if session.getRumbleConf().getBoolean("debug.showErrorInfo"): + raise + raise RumbleError(error) from error + + return wrapped diff --git a/src/jsoniq/sequence.py b/src/jsoniq/sequence.py index e1bcd08..93c19d8 100644 --- a/src/jsoniq/sequence.py +++ b/src/jsoniq/sequence.py @@ -3,6 +3,7 @@ from pyspark.sql import DataFrame import json import sys +from .errors import notebook_errors class SequenceOfItems: schema_str = """ @@ -32,40 +33,55 @@ def __init__(self, sequence, rumblesession): self._sparksession = rumblesession._sparksession self._sparkcontext = self._sparksession.sparkContext + @notebook_errors def items(self): return self.getAsList() + @notebook_errors def take(self, n): self._rumblesession.lastResult = tuple(self.getFirstItemsAsList(n)) return self._rumblesession.lastResult + @notebook_errors def first(self): self._rumblesession.lastResult = tuple(self.getFirstItemsAsList(self._rumblesession.getRumbleConf().getInt("runtime.resultsSizeCap"))) return self._rumblesession.lastResult + @notebook_errors def json(self): self._rumblesession.lastResult = tuple([json.loads(l.serializeAsJSON()) for l in self._jsequence.getAsList()]) return self._rumblesession.lastResult + @notebook_errors def rdd(self): rdd = self._jsequence.getAsPickledStringRDD() rdd = RDD(rdd, self._sparkcontext) self._rumblesession.lastResult = rdd.map(lambda l: json.loads(l)) return self._rumblesession.lastResult + @notebook_errors def df(self): self._rumblesession.lastResult = DataFrame(self._jsequence.getAsDataFrame(), self._sparksession) return self._rumblesession.lastResult + @notebook_errors def pdf(self): self._rumblesession.lastResult = self.df().toPandas() return self._rumblesession.lastResult + @notebook_errors def count(self): return self._jsequence.count() + @notebook_errors def nextJSON(self): return self._jsequence.next().serializeAsJSON() def __getattr__(self, item): - return getattr(self._jsequence, item) \ No newline at end of file + value = getattr(self._jsequence, item) + if callable(value): + @notebook_errors + def call(sequence, *args, **kwargs): + return value(*args, **kwargs) + return call.__get__(self, type(self)) + return value diff --git a/src/jsoniq/session.py b/src/jsoniq/session.py index 98e149d..145227f 100644 --- a/src/jsoniq/session.py +++ b/src/jsoniq/session.py @@ -1,5 +1,6 @@ from pyspark.sql import SparkSession from py4j.java_collections import JavaList +from .errors import notebook_errors from .sequence import SequenceOfItems from .configuration import RumbleConfiguration import sys @@ -333,9 +334,11 @@ def bindDataFrameAsVariable(self, name: str, df): self._bindings[name] = ("bindDataFrame", df._jdf) return self; + @notebook_errors def jsoniq(self, str, **kwargs): return self._run_query(str, self._jrumblesession, kwargs) + @notebook_errors def xquery(self, str, **kwargs): """Run a query with XQuery 3.1 as the default language.""" builder = self._jrumblesession.getConfiguration().toBuilder() diff --git a/tests/test_notebook_errors.py b/tests/test_notebook_errors.py new file mode 100644 index 0000000..83ea72c --- /dev/null +++ b/tests/test_notebook_errors.py @@ -0,0 +1,71 @@ +from unittest.mock import Mock + +import pytest +from py4j.protocol import Py4JJavaError + +from jsoniq import RumbleError, RumbleSession +from jsoniq.sequence import SequenceOfItems + + +def setup_error(debug=False): + java = Mock() + java._target_id = "o1" + java.getMessage.return_value = "Code: [RBST0004]\nMessage: Cannot convert to DataFrame." + error = Py4JJavaError("Java call failed", java) + session = Mock() + session.getRumbleConf.return_value.getBoolean.return_value = debug + return session, error + + +@pytest.mark.parametrize("method", ["df", "pdf", "json", "rdd", "count", "nextJSON", "serialize"]) +def test_result_errors_are_concise_and_catchable(method): + session, error = setup_error() + java_sequence = Mock() + for name in ["getAsDataFrame", "getAsList", "getAsPickledStringRDD", "count", "next", "serialize"]: + getattr(java_sequence, name).side_effect = error + sequence = SequenceOfItems(java_sequence, session) + with pytest.raises(RumbleError) as caught: + getattr(sequence, method)() + assert isinstance(caught.value, Py4JJavaError) + assert caught.value.java_exception is error.java_exception + assert caught.value.__cause__ is error + assert str(caught.value) == error.java_exception.getMessage() + assert caught.value._render_traceback_() == str(caught.value).splitlines() + + +def test_debug_preserves_original_exception(): + session, error = setup_error(debug=True) + java_sequence = Mock() + java_sequence.getAsDataFrame.side_effect = error + with pytest.raises(Py4JJavaError) as caught: + SequenceOfItems(java_sequence, session).pdf() + assert caught.value is error + + +@pytest.mark.parametrize("language", ["jsoniq", "xquery"]) +def test_query_creation_errors(language): + session, error = setup_error() + session._run_query.side_effect = error + with pytest.raises(RumbleError): + getattr(RumbleSession, language)(session, "invalid query") + + +def test_ipython_renders_only_java_message(): + ipython = pytest.importorskip("IPython.core.interactiveshell") + _, error = setup_error() + shell = ipython.InteractiveShell() + output = [] + shell._showtraceback = lambda exception_type, value, traceback: output.extend(traceback) + try: + raise RumbleError(error) from error + except RumbleError: + shell.showtraceback() + assert output == error.java_exception.getMessage().splitlines() + + +def test_non_java_errors_are_unchanged(): + session, _ = setup_error() + java_sequence = Mock() + java_sequence.getAsDataFrame.side_effect = ValueError("Python failure") + with pytest.raises(ValueError, match="Python failure"): + SequenceOfItems(java_sequence, session).df() From d09f21f11ace264fd3b6038d7319b8d780b40bec Mon Sep 17 00:00:00 2001 From: Ghislain Fourny Date: Wed, 11 Nov 2026 15:22:02 +0100 Subject: [PATCH 2/3] Remove README changes from error display proposal --- README.md | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/README.md b/README.md index 913dad1..9fd821e 100644 --- a/README.md +++ b/README.md @@ -543,16 +543,3 @@ rumble.getRumbleConf().set("debug.showErrorInfo", True) - Ability to write back a sequence of items to local disk, HDFS, S3... in various formats (JSON, CSV, Parquet...). - Automatically declare external variables bound as DataFrames to improve userfriendliness. - Simplified the function names to make them more intuitive (json(), items(), df(), rdd(), etc). - -### Error display in notebooks - -Java errors raised by `rumble.jsoniq(...)`, `rumble.xquery(...)`, and sequence -accessors such as `.df()`, `.pdf()`, and `.serialize()` display just the Java -error message in Jupyter, without a Python traceback. No notebook setup is needed. -The failed cell still stops, and Python code can catch `jsoniq.RumbleError` -(which also remains compatible with `Py4JJavaError` handlers). - -Set `rumble.getRumbleConf().set("debug.showErrorInfo", True)` to retain the -original exception and full traceback. Outside IPython, normal Python traceback -reporting still applies. Errors raised later by operations on returned Spark -DataFrames or RDDs use Spark's usual error reporting. From 045cece07d513d088453766b99291d3598fe40f9 Mon Sep 17 00:00:00 2001 From: Ghislain Fourny Date: Wed, 11 Nov 2026 15:26:33 +0100 Subject: [PATCH 3/3] Share notebook error policy across API and magic execution --- src/jsoniq/errors.py | 24 ++++-- src/jsoniqmagic/magic.py | 138 +++--------------------------- tests/test_magic_serialization.py | 7 +- tests/test_notebook_errors.py | 51 +++++++++++ 4 files changed, 82 insertions(+), 138 deletions(-) diff --git a/src/jsoniq/errors.py b/src/jsoniq/errors.py index ac7250c..c136d4e 100644 --- a/src/jsoniq/errors.py +++ b/src/jsoniq/errors.py @@ -1,5 +1,6 @@ """Notebook-friendly Java errors that retain normal exception semantics.""" +from contextlib import contextmanager from functools import wraps from py4j.protocol import Py4JJavaError @@ -23,18 +24,25 @@ def _render_traceback_(self): return str(self).splitlines() +@contextmanager +def notebook_error_context(session): + """Apply the same error policy to an entire query execution block.""" + try: + yield + except RumbleError: + raise + except Py4JJavaError as error: + if session.getRumbleConf().getBoolean("debug.showErrorInfo"): + raise + raise RumbleError(error) from error + + def notebook_errors(method): """Translate Java errors unless full debugging output was requested.""" @wraps(method) def wrapped(self, *args, **kwargs): - try: + session = self.__dict__.get("_rumblesession", self) + with notebook_error_context(session): return method(self, *args, **kwargs) - except RumbleError: - raise - except Py4JJavaError as error: - session = self.__dict__.get("_rumblesession", self) - if session.getRumbleConf().getBoolean("debug.showErrorInfo"): - raise - raise RumbleError(error) from error return wrapped diff --git a/src/jsoniqmagic/magic.py b/src/jsoniqmagic/magic.py index 8d250dd..46cd75e 100644 --- a/src/jsoniqmagic/magic.py +++ b/src/jsoniqmagic/magic.py @@ -4,7 +4,7 @@ ) import time, json from jsoniq.session import RumbleSession -from py4j.protocol import Py4JJavaError +from jsoniq.errors import notebook_error_context @magics_class class JSONiqMagic(Magics): @@ -39,153 +39,39 @@ def run(self, line, cell=None, timed=False, language="jsoniq"): ): args.serialize = True start = time.time() - try: - rumble = RumbleSession.builder.getOrCreate(); - response = getattr(rumble, language)(data); - except Py4JJavaError as e: - print(e.java_exception.getMessage()) - return - except Exception as e: - print("Query unsuccessful.") - print("Usual reasons: firewall, misconfigured proxy.") - print("Error message:") - print(e.args[0]) - return - except: - print("Query unsuccessful.") - print("Usual reasons: firewall, misconfigured proxy.") - return + rumble = RumbleSession.builder.getOrCreate(); + with notebook_error_context(rumble): + return self._execute(rumble, language, data, args, start) + + def _execute(self, rumble, language, data, args, start): + response = getattr(rumble, language)(data); if args.serialize: - try: - serialized = response.serialize() - except Py4JJavaError as e: - if rumble.getRumbleConf().getBoolean("debug.showErrorInfo"): - raise - print(e.java_exception.getMessage()) - return - except Exception as e: - print("Query unsuccessful.") - print("Usual reasons: firewall, misconfigured proxy.") - print("Error message:") - print(e.args[0]) - return - except: - print("Query unsuccessful.") - print("Usual reasons: firewall, misconfigured proxy.") - return + serialized = response.serialize() print(serialized) if args.timed: print("Response time: %s ms" % (time.time() - start)) return - schema_str = """ -No DataFrame available as no schema was detected. If you still believe the output is structured enough, you could add a schema and validate expression explicitly to your query. - -This is an example of how you can simply define a schema and wrap your query in a validate expression: - -declare type mytype as { - "product" : "string", - "store-number" : "int", - "quantity" : "decimal" -}; -validate type mytype* { - for $product in json-lines("http://rumbledb.org/samples/products-small.json", 10) - where $product.quantity ge 995 - return $product -} -""" - if(args.pyspark_data_frame): - try: - df = response.df(); - except Py4JJavaError as e: - if rumble.getRumbleConf().getBoolean("debug.showErrorInfo") : - raise e; - else: - print(e.java_exception.getMessage()) - return - except Exception as e: - if rumble.getRumbleConf().getBoolean("debug.showErrorInfo") : - raise e; - else: - print("Query unsuccessful.") - print("Usual reasons: firewall, misconfigured proxy.") - print("Error message:") - print(e.args[0]) - return - except: - print("Query unsuccessful.") - print("Usual reasons: firewall, misconfigured proxy.") - return + df = response.df(); if df is not None: df.show() if (args.pandas_data_frame): - try: - pdf = response.pdf() - except Py4JJavaError as e: - if rumble.getRumbleConf().getBoolean("debug.showErrorInfo") : - raise e; - else: - print(e.java_exception.getMessage()) - return - except Exception as e: - print("Query unsuccessful.") - print("Usual reasons: firewall, misconfigured proxy.") - print("Error message:") - print(e.args[0]) - return - except: - print("Query unsuccessful.") - print("Usual reasons: firewall, misconfigured proxy.") - return + pdf = response.pdf() if pdf is not None: return pdf if (args.apply_updates): if ("PUL" in response.availableOutputs()): - try: - response.applyPUL() - except Py4JJavaError as e: - if rumble.getRumbleConf().getBoolean("debug.showErrorInfo") : - raise e; - else: - print(e.java_exception.getMessage()) - return - except Exception as e: - print("Query unsuccessful.") - print("Usual reasons: firewall, misconfigured proxy.") - print("Error message:") - print(e.args[0]) - return - except: - print("Query unsuccessful.") - print("Usual reasons: firewall, misconfigured proxy.") - return + response.applyPUL() print("Updates applied successfully.") else: print("No Pending Update List (PUL) available to apply.") if (args.json or (not args.pandas_data_frame and not args.pyspark_data_frame)): - try: - capplusone = response.take(rumble.getRumbleConf().getInt("runtime.resultsSizeCap") + 1) - except Py4JJavaError as e: - if rumble.getRumbleConf().getBoolean("debug.showErrorInfo") : - raise e; - else: - print(e.java_exception.getMessage()) - return - except Exception as e: - print("Query unsuccessful.") - print("Usual reasons: firewall, misconfigured proxy.") - print("Error message:") - print(e.args[0]) - return - except: - print("Query unsuccessful.") - print("Usual reasons: firewall, misconfigured proxy.") - return + capplusone = response.take(rumble.getRumbleConf().getInt("runtime.resultsSizeCap") + 1) if len(capplusone) > rumble.getRumbleConf().getInt("runtime.resultsSizeCap"): count = response.count() print("The query output %s items, which is too many to display. Displaying the first %s items:" % (count, rumble.getRumbleConf().getInt("runtime.resultsSizeCap"))) diff --git a/tests/test_magic_serialization.py b/tests/test_magic_serialization.py index 37cd683..5f13b88 100644 --- a/tests/test_magic_serialization.py +++ b/tests/test_magic_serialization.py @@ -27,14 +27,13 @@ def test_magic_prints_serialized_sequence_directly(option, serialized, capsys): assert capsys.readouterr().out == serialized + "\n" -def test_magic_reports_serialization_errors(capsys): +def test_magic_preserves_python_serialization_errors(): session = Mock() session.jsoniq.return_value.serialize.side_effect = RuntimeError("Serialization failed") with patch.object(RumbleSession.Builder, "getOrCreate", return_value=session): - JSONiqMagic().jsoniq("-s", "1 to 3") - - assert "Serialization failed" in capsys.readouterr().out + with pytest.raises(RuntimeError, match="Serialization failed"): + JSONiqMagic().jsoniq("-s", "1 to 3") def test_magic_serialization_preserves_timing(capsys): diff --git a/tests/test_notebook_errors.py b/tests/test_notebook_errors.py index 83ea72c..049fb77 100644 --- a/tests/test_notebook_errors.py +++ b/tests/test_notebook_errors.py @@ -69,3 +69,54 @@ def test_non_java_errors_are_unchanged(): java_sequence.getAsDataFrame.side_effect = ValueError("Python failure") with pytest.raises(ValueError, match="Python failure"): SequenceOfItems(java_sequence, session).df() + + +@pytest.mark.parametrize("language", ["jsoniq", "xquery"]) +@pytest.mark.parametrize("debug", [False, True]) +@pytest.mark.parametrize("stage,option", [ + ("query", "-j"), ("serialize", "-s"), ("df", "-df"), + ("show", "-df"), ("pdf", "-pdf"), ("availableOutputs", "-u"), + ("applyPUL", "-u"), ("take", "-j"), ("count", "-j"), + ("serializeAsJSON", "-j"), +]) +def test_magic_uses_shared_error_policy(language, debug, stage, option): + pytest.importorskip("IPython") + from unittest.mock import patch + from jsoniqmagic import JSONiqMagic + + session, error = setup_error(debug) + response = getattr(session, language).return_value + session.getRumbleConf.return_value.getInt.return_value = 1 + response.availableOutputs.return_value = ["PUL"] + item = Mock() + response.take.return_value = [item, item] + if stage == "query": + target = getattr(session, language) + elif stage == "show": + target = response.df.return_value.show + elif stage == "serializeAsJSON": + target = item.serializeAsJSON + response.take.return_value = [item] + else: + target = getattr(response, stage) + target.side_effect = error + with patch.object(RumbleSession.Builder, "getOrCreate", return_value=session): + with pytest.raises(Py4JJavaError) as caught: + getattr(JSONiqMagic(), language)(option, "query") + if debug: + assert caught.value is error + else: + assert isinstance(caught.value, RumbleError) + assert str(caught.value) == error.java_exception.getMessage() + + +def test_nested_error_handlers_preserve_translated_error(): + from jsoniq.errors import notebook_error_context + + session, error = setup_error() + translated = RumbleError(error) + with pytest.raises(RumbleError) as caught: + with notebook_error_context(session): + raise translated + assert caught.value is translated + session.getRumbleConf.assert_not_called()