From 8f4d42f00301fcec3b9fecdb3ab3a8cba8885c9d Mon Sep 17 00:00:00 2001 From: Vishal Rane Date: Wed, 15 Jul 2026 17:50:19 +0530 Subject: [PATCH 1/4] Capture and add warnings separately in AnalyticResult from standard error in analytic results --- spotfire/data_function.py | 22 +++++++- .../files/data_function/table_metadata.txt | 2 +- .../files/data_function/warning_pysrv79.txt | 2 +- spotfire/test/test_data_function.py | 52 +++++++++++++++++-- 4 files changed, 72 insertions(+), 6 deletions(-) diff --git a/spotfire/data_function.py b/spotfire/data_function.py index ce64edd..e9f6d98 100644 --- a/spotfire/data_function.py +++ b/spotfire/data_function.py @@ -12,6 +12,7 @@ import types import typing import re +import warnings from spotfire import sbdf, _utils @@ -25,6 +26,7 @@ _COLUMN_METADATA_TRUNCATE_THRESHOLD = 80000 +_MAX_WARNINGS = 100 def _bad_string(str_: typing.Any) -> bool: @@ -220,7 +222,9 @@ def write(self, globals_dict: _Globals, debug_fn: _LogFunction) -> None: class AnalyticResult: """Represents the results of evaluating an AnalyticSpec object.""" + # pylint: disable=too-many-instance-attributes std_err_out: typing.Optional[str] + warnings: list[str] summary: typing.Optional[str] _exc_info: _ExceptionInfo _debug_log: typing.Optional[str] @@ -228,7 +232,9 @@ class AnalyticResult: def __init__(self, capture: _OutputCapture) -> None: self.success = True self.has_stderr = False + self.has_warnings = False self.std_err_out = None + self.warnings = [] self.summary = None self._exc_info = (None, None, None) self._capture = capture @@ -413,9 +419,20 @@ def _execute_script(self, compiled_script: types.CodeType, result: AnalyticResul if self.analytic_type == "script": # noinspection PyBroadException try: - exec(compiled_script, self.globals) + with warnings.catch_warnings(record=True) as caught_warnings: + exec(compiled_script, self.globals) except BaseException: result.fail_with_exception(sys.exc_info()) + if caught_warnings: + result.has_warnings = True + total = len(caught_warnings) + result.warnings = [ + warnings.formatwarning(w.message, w.category, w.filename, w.lineno, w.line) + for w in caught_warnings[:_MAX_WARNINGS] + ] + if total > _MAX_WARNINGS: + result.warnings.append(f"... and {total - _MAX_WARNINGS} more warnings (truncated)\n") + if not result.success: return elif self.analytic_type == "aggregationScript": self.debug("aggregation scripts are not supported") @@ -489,6 +506,9 @@ def _create_summary(self, result: AnalyticResult) -> None: result.has_stderr = True buf.write("\nStandard error:\n") buf.write(result.std_err_out) + if result.has_warnings: + buf.write("\nWarnings:\n") + buf.writelines(result.warnings) if self.debug_enabled: debug_log = result.get_debug_log() if debug_log: diff --git a/spotfire/test/files/data_function/table_metadata.txt b/spotfire/test/files/data_function/table_metadata.txt index 622f55f..7ef70db 100644 --- a/spotfire/test/files/data_function/table_metadata.txt +++ b/spotfire/test/files/data_function/table_metadata.txt @@ -1,3 +1,3 @@ -Standard error: +Warnings: :3: UserWarning: Pandas doesn't allow columns to be created via a new attribute name - see https://pandas.pydata.org/pandas-docs/stable/indexing.html#attribute-access diff --git a/spotfire/test/files/data_function/warning_pysrv79.txt b/spotfire/test/files/data_function/warning_pysrv79.txt index f46eb88..aa5f766 100644 --- a/spotfire/test/files/data_function/warning_pysrv79.txt +++ b/spotfire/test/files/data_function/warning_pysrv79.txt @@ -1,5 +1,5 @@ -Standard error: +Warnings: :4: Warning: This is a Warning :5: UserWarning: This is a UserWarning :6: DeprecationWarning: This is a DeprecationWarning diff --git a/spotfire/test/test_data_function.py b/spotfire/test/test_data_function.py index d5e61f1..9f7ff77 100644 --- a/spotfire/test/test_data_function.py +++ b/spotfire/test/test_data_function.py @@ -44,7 +44,8 @@ class DataFunctionTest(unittest.TestCase): """Unit tests for public functions in 'spotfire.data_function' module.""" # pylint: disable=too-many-branches, too-many-statements, too-many-arguments, too-many-public-methods - def _run_analytic(self, script, inputs, outputs, success, expected_result, spec_adjust=None) -> None: + def _run_analytic(self, script, inputs, outputs, success, expected_result, spec_adjust=None, + has_warnings=None, has_stderr=None) -> None: """Run a full pass through the analytic protocol, and compare the output to the expected value.""" # pylint: disable=too-many-positional-arguments,protected-access,too-many-locals with _utils.TempFiles() as temp_files: @@ -106,6 +107,10 @@ def _run_analytic(self, script, inputs, outputs, success, expected_result, spec_ if not actual_result.success: print("test: data function has failed") self.assertEqual(actual_result.success, success) + if has_warnings is not None: + self.assertEqual(actual_result.has_warnings, has_warnings) + if has_stderr is not None: + self.assertEqual(actual_result.has_stderr, has_stderr) print("test: done evaluating spec") for output in output_spec: @@ -249,7 +254,7 @@ def test_exception_pysrv78(self): print("But not this.")""", {}, {}, False, expected) def test_warning_pysrv79(self): - """Test that warnings are returned.""" + """Test that warnings are returned separately from stderr.""" expected = _PythonVersionedExpectedValue("warning_pysrv79") self._run_analytic("""import warnings warnings.simplefilter("always") @@ -264,7 +269,48 @@ def test_warning_pysrv79(self): warn("This is a ImportWarning", ImportWarning) warn("This is a UnicodeWarning", UnicodeWarning) warn("This is a BytesWarning", BytesWarning) -warn("This is a ResourceWarning", ResourceWarning)""", {}, {}, True, expected) +warn("This is a ResourceWarning", ResourceWarning)""", {}, {}, True, expected, + has_warnings=True, has_stderr=False) + + def test_warning_pandas(self): + """Test that pandas-generated warnings are captured separately from stderr.""" + in1_df = pd.DataFrame({"a": [1.0, 2.0, 3.0]}) + expected = _PythonVersionedExpectedValue("warning_pandas") + self._run_analytic("""import pandas as pd +df = pd.DataFrame({"x": [1, 2, 3]}) +df.new_col = [4, 5, 6] +output = in1""", {"in1": in1_df}, {"output": in1_df}, True, expected, + has_warnings=True, has_stderr=False) + + def test_warning_per_row(self): + """Test that per-row warnings are deduplicated by Python's default filter.""" + in1_df = pd.DataFrame({"a": pd.array([1, 2, 3, 4, 5], dtype="Int64")}) + expected = _PythonVersionedExpectedValue("warning_per_row") + self._run_analytic("""import warnings +for idx, row in in1.iterrows(): + warnings.warn(f"bad value at row {idx}") +output = in1""", {"in1": in1_df}, {"output": in1_df}, True, expected, + has_warnings=True, has_stderr=False) + + def test_warning_truncated(self): + """Test that warnings are truncated to _MAX_WARNINGS.""" + spec = datafn.AnalyticSpec("script", [], [], """import warnings +warnings.simplefilter("always") +for i in range(150): + warnings.warn(f"warning {i}")""") + result = spec.evaluate() + self.assertTrue(result.has_warnings) + self.assertEqual(len(result.warnings), 101) + self.assertIn("50 more warnings (truncated)", result.warnings[-1]) + + def test_warning_suppressed(self): + """Test that warnings.simplefilter('ignore') suppresses warning capture.""" + self._run_analytic("""import warnings +warnings.simplefilter("ignore") +import pandas as pd +df = pd.DataFrame({"x": [1, 2, 3]}) +df.new_col = [4, 5, 6]""", {}, {}, True, None, + has_warnings=False, has_stderr=False) def test_stderr_pysrv116(self): """Test that stdout is returned correctly with stderr""" From e6e1d6cb303aec5f6bd1a0df5763d0e7b4703b08 Mon Sep 17 00:00:00 2001 From: Vishal Rane Date: Wed, 15 Jul 2026 17:55:33 +0530 Subject: [PATCH 2/4] Adding missing warning_per_row.txt --- spotfire/test/files/data_function/warning_per_row.txt | 7 +++++++ spotfire/test/test_data_function.py | 9 --------- 2 files changed, 7 insertions(+), 9 deletions(-) create mode 100644 spotfire/test/files/data_function/warning_per_row.txt diff --git a/spotfire/test/files/data_function/warning_per_row.txt b/spotfire/test/files/data_function/warning_per_row.txt new file mode 100644 index 0000000..8d6b017 --- /dev/null +++ b/spotfire/test/files/data_function/warning_per_row.txt @@ -0,0 +1,7 @@ + +Warnings: +:3: UserWarning: bad value at row 0 +:3: UserWarning: bad value at row 1 +:3: UserWarning: bad value at row 2 +:3: UserWarning: bad value at row 3 +:3: UserWarning: bad value at row 4 diff --git a/spotfire/test/test_data_function.py b/spotfire/test/test_data_function.py index 9f7ff77..eb3a36f 100644 --- a/spotfire/test/test_data_function.py +++ b/spotfire/test/test_data_function.py @@ -272,15 +272,6 @@ def test_warning_pysrv79(self): warn("This is a ResourceWarning", ResourceWarning)""", {}, {}, True, expected, has_warnings=True, has_stderr=False) - def test_warning_pandas(self): - """Test that pandas-generated warnings are captured separately from stderr.""" - in1_df = pd.DataFrame({"a": [1.0, 2.0, 3.0]}) - expected = _PythonVersionedExpectedValue("warning_pandas") - self._run_analytic("""import pandas as pd -df = pd.DataFrame({"x": [1, 2, 3]}) -df.new_col = [4, 5, 6] -output = in1""", {"in1": in1_df}, {"output": in1_df}, True, expected, - has_warnings=True, has_stderr=False) def test_warning_per_row(self): """Test that per-row warnings are deduplicated by Python's default filter.""" From a67793750cf446d5d84ba31dd2d262e1e0376aeb Mon Sep 17 00:00:00 2001 From: Vishal Rane Date: Wed, 15 Jul 2026 18:07:45 +0530 Subject: [PATCH 3/4] fixing static analysis errors --- spotfire/test/files/data_function/warning_per_row.txt | 10 +++++----- spotfire/test/test_data_function.py | 3 ++- spotfire/test/test_sbdf.py | 6 ++++-- 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/spotfire/test/files/data_function/warning_per_row.txt b/spotfire/test/files/data_function/warning_per_row.txt index 8d6b017..d403cdd 100644 --- a/spotfire/test/files/data_function/warning_per_row.txt +++ b/spotfire/test/files/data_function/warning_per_row.txt @@ -1,7 +1,7 @@ Warnings: -:3: UserWarning: bad value at row 0 -:3: UserWarning: bad value at row 1 -:3: UserWarning: bad value at row 2 -:3: UserWarning: bad value at row 3 -:3: UserWarning: bad value at row 4 +:4: UserWarning: bad value at row 0 +:4: UserWarning: bad value at row 1 +:4: UserWarning: bad value at row 2 +:4: UserWarning: bad value at row 3 +:4: UserWarning: bad value at row 4 diff --git a/spotfire/test/test_data_function.py b/spotfire/test/test_data_function.py index eb3a36f..9afefbc 100644 --- a/spotfire/test/test_data_function.py +++ b/spotfire/test/test_data_function.py @@ -274,10 +274,11 @@ def test_warning_pysrv79(self): def test_warning_per_row(self): - """Test that per-row warnings are deduplicated by Python's default filter.""" + """Test that unique per-row warnings are each captured individually.""" in1_df = pd.DataFrame({"a": pd.array([1, 2, 3, 4, 5], dtype="Int64")}) expected = _PythonVersionedExpectedValue("warning_per_row") self._run_analytic("""import warnings +warnings.simplefilter("always") for idx, row in in1.iterrows(): warnings.warn(f"bad value at row {idx}") output = in1""", {"in1": in1_df}, {"output": in1_df}, True, expected, diff --git a/spotfire/test/test_sbdf.py b/spotfire/test/test_sbdf.py index de89774..d043bf8 100644 --- a/spotfire/test/test_sbdf.py +++ b/spotfire/test/test_sbdf.py @@ -445,7 +445,8 @@ def test_numpy_datetime_resolution(self): } for resolution, timestamp in inputs.items(): with self.subTest(resolution=resolution): - array = np.array([[0], [timestamp]]).astype(f"datetime64[{resolution}]") + array: np.ndarray = np.array([[0], [timestamp]]) + array = array.astype(f"datetime64[{resolution}]") dataframe = pd.DataFrame(array, columns=["x"]) df2 = self._roundtrip_dataframe(dataframe) val = df2.at[1, 'x'] @@ -462,7 +463,8 @@ def test_numpy_timedelta_resolution(self): } for resolution, timestamp in inputs.items(): with self.subTest(resolution=resolution): - array = np.array([[0], [timestamp]]).astype(f"timedelta64[{resolution}]") + array: np.ndarray = np.array([[0], [timestamp]]) + array = array.astype(f"timedelta64[{resolution}]") dataframe = pd.DataFrame(array, columns=["x"]) df2 = self._roundtrip_dataframe(dataframe) val = df2.at[1, 'x'] From f65bd7c80d2628e7597e345ebfa6f0561e561af7 Mon Sep 17 00:00:00 2001 From: Vishal Rane Date: Thu, 16 Jul 2026 15:19:07 +0530 Subject: [PATCH 4/4] Fix warning truncation logic to correctly handle the number of captured warnings --- spotfire/data_function.py | 15 ++++++++++----- spotfire/test/test_data_function.py | 4 ++-- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/spotfire/data_function.py b/spotfire/data_function.py index e9f6d98..8e3637e 100644 --- a/spotfire/data_function.py +++ b/spotfire/data_function.py @@ -426,12 +426,17 @@ def _execute_script(self, compiled_script: types.CodeType, result: AnalyticResul if caught_warnings: result.has_warnings = True total = len(caught_warnings) - result.warnings = [ - warnings.formatwarning(w.message, w.category, w.filename, w.lineno, w.line) - for w in caught_warnings[:_MAX_WARNINGS] - ] if total > _MAX_WARNINGS: - result.warnings.append(f"... and {total - _MAX_WARNINGS} more warnings (truncated)\n") + result.warnings = [ + warnings.formatwarning(w.message, w.category, w.filename, w.lineno, w.line) + for w in caught_warnings[:_MAX_WARNINGS - 1] + ] + result.warnings.append(f"... and {total - _MAX_WARNINGS + 1} more warnings (truncated)\n") + else: + result.warnings = [ + warnings.formatwarning(w.message, w.category, w.filename, w.lineno, w.line) + for w in caught_warnings + ] if not result.success: return elif self.analytic_type == "aggregationScript": diff --git a/spotfire/test/test_data_function.py b/spotfire/test/test_data_function.py index 9afefbc..ef33c61 100644 --- a/spotfire/test/test_data_function.py +++ b/spotfire/test/test_data_function.py @@ -292,8 +292,8 @@ def test_warning_truncated(self): warnings.warn(f"warning {i}")""") result = spec.evaluate() self.assertTrue(result.has_warnings) - self.assertEqual(len(result.warnings), 101) - self.assertIn("50 more warnings (truncated)", result.warnings[-1]) + self.assertEqual(len(result.warnings), 100) + self.assertIn("51 more warnings (truncated)", result.warnings[-1]) def test_warning_suppressed(self): """Test that warnings.simplefilter('ignore') suppresses warning capture."""