diff --git a/spotfire/data_function.py b/spotfire/data_function.py index ce64edd..8e3637e 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,25 @@ 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) + if total > _MAX_WARNINGS: + 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": self.debug("aggregation scripts are not supported") @@ -489,6 +511,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_per_row.txt b/spotfire/test/files/data_function/warning_per_row.txt new file mode 100644 index 0000000..d403cdd --- /dev/null +++ b/spotfire/test/files/data_function/warning_per_row.txt @@ -0,0 +1,7 @@ + +Warnings: +: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/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..ef33c61 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,40 @@ 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_per_row(self): + """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, + 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), 100) + self.assertIn("51 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""" 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']