diff --git a/superset/common/query_object.py b/superset/common/query_object.py index b6dcbe65f628..1536ec3137d7 100644 --- a/superset/common/query_object.py +++ b/superset/common/query_object.py @@ -772,6 +772,27 @@ def exec_post_processing(self, df: DataFrame) -> DataFrame: options = post_process.get("options", {}) if operation == "resample": options = self._resolve_resample_options(options) + # Bind before calling so that a stored option set that does not + # fit the operation (e.g. a missing required option) is reported + # as a validation error, while a TypeError raised from inside the + # operation itself still propagates unchanged. + try: + signature = inspect.signature(func) + except (TypeError, ValueError): + # Some callables expose no signature; skip the pre-check. + signature = None + try: + if signature is not None: + signature.bind(df, **options) + except TypeError as ex: + raise InvalidPostProcessingError( + _( + "Invalid options for post processing operation " + "%(operation)s: %(error)s", + operation=operation, + error=str(ex), + ) + ) from ex df = func(df, **options) return df diff --git a/tests/unit_tests/queries/query_object_test.py b/tests/unit_tests/queries/query_object_test.py index 6163864d64b6..f5026c7b6ee9 100644 --- a/tests/unit_tests/queries/query_object_test.py +++ b/tests/unit_tests/queries/query_object_test.py @@ -929,3 +929,59 @@ def test_cache_key_ignores_bounds_when_time_range_is_set(): query_object2 = QueryObject(time_range="Last week", from_dttm=datetime(1970, 1, 1)) assert query_object1.cache_key() == query_object2.cache_key() + + +def test_exec_post_processing_missing_required_option_raises( + app_context: None, +) -> None: + """ + A stored operation lacking a required option is reported as + InvalidPostProcessingError naming the operation and the missing option, + rather than escaping as a bare TypeError. + """ + query_object = QueryObject( + row_limit=10, + post_processing=[{"operation": "cum", "options": {"operator": "sum"}}], + ) + + with pytest.raises(InvalidPostProcessingError) as excinfo: + query_object.exec_post_processing(DataFrame({"y": [1, 2, 3]})) + + assert "cum" in str(excinfo.value.message) + assert "columns" in str(excinfo.value.message) + + +def test_exec_post_processing_valid_options_still_apply(app_context: None) -> None: + query_object = QueryObject( + row_limit=10, + post_processing=[ + {"operation": "cum", "options": {"operator": "sum", "columns": {"y": "y"}}} + ], + ) + + result = query_object.exec_post_processing(DataFrame({"y": [1, 2, 3]})) + + assert list(result["y"]) == [1, 3, 6] + + +def test_exec_post_processing_type_error_inside_operation_propagates( + app_context: None, +) -> None: + """A TypeError from the operation body is not turned into a validation error.""" + + def _boom(df: DataFrame, column: str) -> DataFrame: + raise TypeError("boom from inside the operation") + + query_object = QueryObject( + row_limit=10, + post_processing=[{"operation": "_boom", "options": {"column": "y"}}], + ) + + with ( + patch.dict( + "superset.common.query_object.current_app.config", + {"EXTRA_PANDAS_POSTPROCESSING_OPS": [_boom]}, + ), + pytest.raises(TypeError, match="boom from inside"), + ): + query_object.exec_post_processing(DataFrame({"y": [1, 2, 3]}))