This is an automated email from the ASF dual-hosted git repository. github-merge-queue[bot] pushed a commit to branch gh-readonly-queue/main/pr-8769-fc62c09a27bf5e9aa73027ce9b37fec5fb5bd018 in repository https://gitbox.apache.org/repos/asf/texera.git
commit 1d8351b1bbe1226ccc74e832c3017626f734920d Author: Suryaa Charan <[email protected]> AuthorDate: Thu Oct 15 18:22:52 2026 +0000 feat(pyamber): clear errors for return-instead-of-yield and output mismatches (#8769) ### What changes were proposed in this PR? Clear messages for the common Python UDF mistakes that show up while the workflow runs: - **`return` instead of `yield`** in `process_tuple`, `process_batch`, `process_table` or `produce` now says `process_table must yield results, not return them. Replace return x with yield x.` Before, users saw `pampy.MatchError: '_' not provided` or `'NoneType' object is not iterable`. Returning a list still works. - **`__init__` without `super().__init__()`** on a table or batch UDF no longer crashes with `'_TableOperator__table_data'`. The buffers are created on first use, the same way `Operator` already creates its template decoder. - **A DataFrame in a non-binary column** now says which column, and to yield the DataFrame itself or make the column binary. A DataFrame in a BINARY column is pickled as before. - **Output columns that don't match** keep the original message and point to "Retain input columns" and "Extra output column(s)" (or "Columns" for a 1-out Python UDF). Before and after, in the Console: | Before | After | |---|---| |<img width="800" height="288" alt="runtime-before" src="https://github.com/user-attachments/assets/7f854ffe-f5eb-4013-8fe4-9eb3a9e740ff" /> | <img width="800" height="288" alt="runtime-after" src="https://github.com/user-attachments/assets/fbaec933-683f-4dd2-b603-5d988b4f338d" /> | ### Any related issues, documentation, discussions? Part of #8736. Split out of #8737. ### How was this PR tested? New tests in `test_operator.py` (each `return` case, a returned list still working, table and batch UDFs with and without `super().__init__()`) and `test_data_processor.py` (`process_tuple` and `on_finish` returning a value, a DataFrame in BINARY, LARGE_BINARY and STRING columns, a missing or misspelled column next to a DataFrame, a wrong column type). ``` cd amber pytest -m "not integration" src/test/python/core/models/test_operator.py src/test/python/core/runnables/test_data_processor.py pytest -m "not integration" ruff check src/main/python src/test/python && ruff format --check src/main/python src/test/python ``` The screenshots are from a local Texera built from this branch. ### Was this PR authored or co-authored using generative AI tooling? Generated-by: Claude Code (Claude Opus 5.5) --------- Co-authored-by: suryaacharan <[email protected]> Co-authored-by: Claude Opus 5.5 <[email protected]> --- amber/src/main/python/core/models/operator.py | 60 +++++-- .../main/python/core/runnables/data_processor.py | 55 ++++++- amber/src/test/python/core/models/test_operator.py | 111 +++++++++++++ .../python/core/runnables/test_data_processor.py | 175 +++++++++++++++++++++ 4 files changed, 380 insertions(+), 21 deletions(-) diff --git a/amber/src/main/python/core/models/operator.py b/amber/src/main/python/core/models/operator.py index 4e65fbb2ab..bbba116da5 100644 --- a/amber/src/main/python/core/models/operator.py +++ b/amber/src/main/python/core/models/operator.py @@ -29,6 +29,22 @@ from .table import all_output_to_tuple import base64 +def require_yielded(result, method_name: str): + """ + Catch the common case of a user method such as process_table using + `return` instead of `yield`: it rejects a returned None, Tuple, dict or + DataFrame, which would otherwise fail later with a confusing error. + Other values (e.g. a list, or a pandas Series) are passed through + unchecked. + """ + if result is None or isinstance(result, (Tuple, dict, pandas.DataFrame)): + raise TypeError( + f"{method_name} must `yield` results, not `return` them. " + "Replace `return x` with `yield x`." + ) + return result + + class Operator(ABC): """ Abstract base class for all operators. @@ -179,7 +195,7 @@ class SourceOperator(TupleOperatorV2): @overrides.final def on_finish(self, port: int) -> Iterator[Optional[TupleLike]]: # TODO: change on_finish to output Iterator[Union[TupleLike, TableLike, None]] - for i in self.produce(): + for i in require_yielded(self.produce(), "produce"): yield from all_output_to_tuple(i) @overrides.final @@ -197,7 +213,6 @@ class BatchOperator(TupleOperatorV2): def __init__(self): super().__init__() - self.__batch_data: MutableMapping[int, List[Tuple]] = defaultdict(list) self._validate_batch_size(self.BATCH_SIZE) @staticmethod @@ -210,26 +225,35 @@ class BatchOperator(TupleOperatorV2): if value <= 0: raise ValueError("BATCH_SIZE should be positive.") + def _get_batch_data(self) -> MutableMapping[int, List[Tuple]]: + # Created on first use (not in __init__) so a subclass whose __init__ + # skips super().__init__() still works, like _get_template_decoder. + if not hasattr(self, "_BatchOperator__batch_data"): + self._validate_batch_size(self.BATCH_SIZE) + self.__batch_data = defaultdict(list) + return self.__batch_data + @overrides.final def process_tuple(self, tuple_: Tuple, port: int) -> Iterator[Optional[TupleLike]]: - self.__batch_data[port].append(tuple_) - if ( - self.BATCH_SIZE is not None - and len(self.__batch_data[port]) >= self.BATCH_SIZE - ): + batch_data = self._get_batch_data() + batch_data[port].append(tuple_) + if self.BATCH_SIZE is not None and len(batch_data[port]) >= self.BATCH_SIZE: yield from self._process_batch(port) @overrides.final def _process_batch(self, port: int) -> Iterator[Optional[BatchLike]]: + batch_data = self._get_batch_data() batch = Batch( pandas.DataFrame( [ - self.__batch_data[port].pop(0).as_series() - for _ in range(min(len(self.__batch_data[port]), self.BATCH_SIZE)) + batch_data[port].pop(0).as_series() + for _ in range(min(len(batch_data[port]), self.BATCH_SIZE)) ] ) ) - for output_batch in self.process_batch(batch, port): + for output_batch in require_yielded( + self.process_batch(batch, port), "process_batch" + ): if output_batch is not None: if isinstance(output_batch, pandas.DataFrame): # TODO: integrate into Batch as a helper function. @@ -242,7 +266,7 @@ class BatchOperator(TupleOperatorV2): @overrides.final def on_finish(self, port: int) -> Iterator[Optional[BatchLike]]: - while len(self.__batch_data[port]) != 0: + while len(self._get_batch_data()[port]) != 0: yield from self._process_batch(port) @abstractmethod @@ -268,16 +292,22 @@ class TableOperator(TupleOperatorV2): def __init__(self): super().__init__() self._Operator__internal_is_source: bool = False - self.__table_data: Mapping[int, List[Tuple]] = defaultdict(list) + + def _get_table_data(self) -> Mapping[int, List[Tuple]]: + # Created on first use (not in __init__) so a subclass whose __init__ + # skips super().__init__() still works, like _get_template_decoder. + if not hasattr(self, "_TableOperator__table_data"): + self.__table_data = defaultdict(list) + return self.__table_data @overrides.final def process_tuple(self, tuple_: Tuple, port: int) -> Iterator[Optional[TupleLike]]: - self.__table_data[port].append(tuple_) + self._get_table_data()[port].append(tuple_) yield def on_finish(self, port: int) -> Iterator[Optional[TableLike]]: - table = Table(self.__table_data[port]) - yield from self.process_table(table, port) + table = Table(self._get_table_data()[port]) + yield from require_yielded(self.process_table(table, port), "process_table") @abstractmethod def process_table(self, table: Table, port: int) -> Iterator[Optional[TableLike]]: diff --git a/amber/src/main/python/core/runnables/data_processor.py b/amber/src/main/python/core/runnables/data_processor.py index eaee8660cd..4466cc6adb 100644 --- a/amber/src/main/python/core/runnables/data_processor.py +++ b/amber/src/main/python/core/runnables/data_processor.py @@ -16,12 +16,15 @@ # under the License. from contextlib import contextmanager + +import pandas from threading import Event from typing import Iterator, Optional from core.architecture.managers import Context -from core.models import State, TupleLike +from core.models import AttributeType, Schema, State, TupleLike from core.models.internal_marker import EndChannel, PortMarker, StartChannel +from core.models.operator import require_yielded from core.models.table import all_output_to_tuple from core.util import Stoppable from core.util.console_message.replace_print import replace_print @@ -76,7 +79,9 @@ class DataProcessor(Runnable, Stoppable): # Flush the state to MainLoop before producing tuples so the # state and the tuple stream don't share a single switch. self._switch_context() - self._set_output_tuple(executor.on_finish(port_id)) + self._set_output_tuple( + require_yielded(executor.on_finish(port_id), "on_finish") + ) def process_state(self, state: State) -> None: """ @@ -94,7 +99,11 @@ class DataProcessor(Runnable, Stoppable): while not finished_current.is_set(): with self._executor_session() as (executor, port_id): tuple_ = self._context.tuple_processing_manager.get_input_tuple() - self._set_output_tuple(executor.process_tuple(tuple_, port_id)) + self._set_output_tuple( + require_yielded( + executor.process_tuple(tuple_, port_id), "process_tuple" + ) + ) @contextmanager def _executor_session(self, marker_port_id: int | None = None): @@ -132,9 +141,14 @@ class DataProcessor(Runnable, Stoppable): # output could be a None, a TupleLike, or a TableLike. for output_tuple in all_output_to_tuple(output): if output_tuple is not None: - output_tuple.finalize( - self._context.output_manager.get_port().get_schema() - ) + schema = self._context.output_manager.get_port().get_schema() + try: + output_tuple.finalize(schema) + except (KeyError, TypeError) as error: + # Only explain on failure, so valid rows are not inspected. + raise self._explain_schema_mismatch( + output, schema, error + ) from error self._switch_context() self._context.tuple_processing_manager.current_output_tuple = ( output_tuple @@ -142,6 +156,35 @@ class DataProcessor(Runnable, Stoppable): self._switch_context() self._context.tuple_processing_manager.finished_current.set() + @staticmethod + def _explain_schema_mismatch(output, schema: Schema, error: Exception) -> Exception: + """ + Rebuild a schema check failure with a hint on how to fix the output, + keeping the original message. The schema check reports missing or + extra columns (KeyError) before wrong types (TypeError), so only a + TypeError can be caused by a DataFrame sitting in a column; a + DataFrame is valid in a BINARY column, where it is pickled. + """ + message = error.args[0] if error.args else str(error) + if isinstance(error, TypeError) and isinstance(output, dict): + columns = set(schema.get_attr_names()) + for key, value in output.items(): + if ( + isinstance(value, pandas.DataFrame) + and key in columns + and schema.get_attr_type(key) != AttributeType.BINARY + ): + return TypeError( + f"{message} A DataFrame was put in column '{key}'. To " + "output its rows, yield the DataFrame itself instead of " + "a dict; to keep it as one value, make the column binary." + ) + return type(error)( + f"{message} The yielded columns and their types must match the " + "operator's output columns: 'Retain input columns' and 'Extra output " + "column(s)' for a Python UDF, or 'Columns' for a 1-out Python UDF." + ) + def _set_output_state(self, output_state: State) -> None: """ Set the output state after processing by the executor. diff --git a/amber/src/test/python/core/models/test_operator.py b/amber/src/test/python/core/models/test_operator.py index bb18853350..6ca9ed5658 100644 --- a/amber/src/test/python/core/models/test_operator.py +++ b/amber/src/test/python/core/models/test_operator.py @@ -514,3 +514,114 @@ class TestBatchOperatorBatchAssembly: assert op.seen == [[[1], [2]]] # the port-1 tuple is not in here assert op.ports == [0] assert out == [Tuple({"batched": 2})] + + +class _TableWithoutSuperInit(TableOperator): + def __init__(self): + self.threshold = 1 # forgets super().__init__() + + def process_table(self, table, port): + yield table + + +class _BatchWithoutSuperInit(BatchOperator): + BATCH_SIZE = 2 + + def __init__(self): + self.seen = 0 # forgets super().__init__() + + def process_batch(self, batch, port): + yield batch + + +class _TableReturningDataFrame(TableOperator): + def process_table(self, table, port): + return table + + +class _TableReturningNone(TableOperator): + def process_table(self, table, port): + return None + + +class _TableReturningList(TableOperator): + def process_table(self, table, port): + return [{"x": 1}] + + +class _BatchReturningBatch(BatchOperator): + BATCH_SIZE = 1 + + def process_batch(self, batch, port): + return batch + + +class _SourceReturningDict(SourceOperator): + def produce(self): + return {"x": 1} + + +class TestCommonUDFMistakes: + """Mistakes UDF authors make should either work or say how to fix them.""" + + def test_table_operator_works_without_super_init(self): + op = _TableWithoutSuperInit() + list(op.process_tuple(Tuple({"x": 1}), 0)) + (table,) = list(op.on_finish(0)) + assert list(table["x"]) == [1] + + def test_batch_operator_works_without_super_init(self): + op = _BatchWithoutSuperInit() + list(op.process_tuple(Tuple({"x": 1}), 0)) + outputs = list(op.process_tuple(Tuple({"x": 2}), 0)) + assert [t["x"] for t in outputs] == [1, 2] + + @pytest.mark.parametrize( + "operator", [_TableReturningDataFrame, _TableReturningNone] + ) + def test_process_table_return_says_to_yield(self, operator): + op = operator() + list(op.process_tuple(Tuple({"x": 1}), 0)) + with pytest.raises(TypeError) as exc_info: + list(op.on_finish(0)) + assert "process_table must `yield` results, not `return` them" in str( + exc_info.value + ) + + def test_process_table_returning_a_list_still_works(self): + op = _TableReturningList() + assert list(op.on_finish(0)) == [{"x": 1}] + + def test_process_batch_return_says_to_yield(self): + op = _BatchReturningBatch() + with pytest.raises(TypeError) as exc_info: + list(op.process_tuple(Tuple({"x": 1}), 0)) + assert "process_batch must `yield` results" in str(exc_info.value) + + def test_produce_return_says_to_yield(self): + op = _SourceReturningDict() + with pytest.raises(TypeError) as exc_info: + list(op.on_finish(0)) + assert "produce must `yield` results" in str(exc_info.value) + + +class TestBufferCreatedOnlyByGetter: + def test_table_operator_has_no_buffer_until_first_tuple(self): + op = _TableWithoutSuperInit() + assert "_TableOperator__table_data" not in op.__dict__ + + class _WithSuper(TableOperator): + def process_table(self, table, port): + yield table + + assert "_TableOperator__table_data" not in _WithSuper().__dict__ + + def test_batch_operator_still_rejects_a_bad_batch_size_at_construction(self): + class _BadSize(BatchOperator): + BATCH_SIZE = 0 + + def process_batch(self, batch, port): + yield batch + + with pytest.raises(ValueError): + _BadSize() diff --git a/amber/src/test/python/core/runnables/test_data_processor.py b/amber/src/test/python/core/runnables/test_data_processor.py index e185872f2e..6b321dd299 100644 --- a/amber/src/test/python/core/runnables/test_data_processor.py +++ b/amber/src/test/python/core/runnables/test_data_processor.py @@ -290,3 +290,178 @@ class TestRunInvariant: assert "expected exactly one queued input" in str(excinfo.value) assert "marker=True" in str(excinfo.value) assert "tuple=True" in str(excinfo.value) + + +class _ReturningExecutor: + """process_tuple uses `return` instead of `yield`.""" + + def process_tuple(self, tuple_, port_id): + return tuple_ + + +class TestUDFOutputMistakes: + @pytest.fixture + def output_schema(self, context, monkeypatch): + schema = Schema(raw_schema={"x": "INTEGER"}) + monkeypatch.setattr( + context.output_manager, + "get_port", + lambda *args, **kwargs: SimpleNamespace(get_schema=lambda: schema), + ) + return schema + + @pytest.mark.timeout(2) + def test_process_tuple_return_says_to_yield(self, context, data_processor): + context.executor_manager.executor = _ReturningExecutor() + context.tuple_processing_manager.current_input_tuple = Tuple({"x": 1}) + + def switch_and_stop(): + data_processor.switch_calls += 1 + context.tuple_processing_manager.finished_current.set() + + data_processor._switch_context = switch_and_stop + data_processor.process_tuple() + + exc_info = context.exception_manager.get_exc_info() + assert exc_info[0] is TypeError + assert "process_tuple must `yield` results, not `return` them" in str( + exc_info[1] + ) + + def test_dict_of_dataframes_says_to_yield_the_dataframe( + self, data_processor, output_schema + ): + import pandas + + with pytest.raises(TypeError) as exc_info: + data_processor._set_output_tuple( + iter([{"x": pandas.DataFrame({"x": [1]})}]) + ) + assert "yield the DataFrame itself instead of a dict" in str(exc_info.value) + + def test_schema_mismatch_points_to_output_columns( + self, data_processor, output_schema + ): + with pytest.raises(KeyError) as exc_info: + data_processor._set_output_tuple(iter([{"y": 1}])) + message = str(exc_info.value) + assert "expected but missing" in message + assert "Extra output column(s)" in message + + +class _FinishReturningExecutor: + """on_finish uses `return` instead of `yield`.""" + + def produce_state_on_finish(self, port_id): + return None + + def on_finish(self, port_id): + return {"x": 1} + + +class TestUDFOutputMistakesFollowUp: + def _use_schema(self, context, monkeypatch, raw_schema): + schema = Schema(raw_schema=raw_schema) + monkeypatch.setattr( + context.output_manager, + "get_port", + lambda *args, **kwargs: SimpleNamespace(get_schema=lambda: schema), + ) + + def test_dict_holding_a_dataframe_into_a_binary_column_still_works( + self, context, data_processor, monkeypatch + ): + import pandas + + # BINARY columns pickle any value, so this is valid output on main. + self._use_schema(context, monkeypatch, {"result": "BINARY"}) + data_processor._set_output_tuple( + iter([{"result": pandas.DataFrame({"x": [1]})}]) + ) + assert context.tuple_processing_manager.finished_current.is_set() + + def test_wrong_column_type_points_to_output_columns( + self, context, data_processor, monkeypatch + ): + self._use_schema(context, monkeypatch, {"x": "INTEGER"}) + with pytest.raises(TypeError) as exc_info: + data_processor._set_output_tuple(iter([{"x": "not a number"}])) + message = str(exc_info.value) + assert "Unmatched type for field 'x'" in message + assert "Extra output column(s)" in message + + @pytest.mark.timeout(2) + def test_on_finish_return_says_to_yield(self, context, data_processor): + context.executor_manager.executor = _FinishReturningExecutor() + data_processor.process_internal_marker(EndChannel(0)) + exc_info = context.exception_manager.get_exc_info() + assert exc_info[0] is TypeError + assert "on_finish must `yield` results, not `return` them" in str(exc_info[1]) + + def test_misspelled_column_next_to_a_dataframe_gets_the_column_hint( + self, context, data_processor, monkeypatch + ): + import pandas + + # The DataFrame is fine (BINARY column); the column name is wrong. + self._use_schema(context, monkeypatch, {"result": "BINARY"}) + with pytest.raises(KeyError) as exc_info: + data_processor._set_output_tuple( + iter([{"res": pandas.DataFrame({"x": [1]})}]) + ) + message = str(exc_info.value) + assert "Extra output column(s)" in message + assert "dict of DataFrames" not in message + + def test_dataframe_in_a_non_binary_column_says_to_yield_the_dataframe( + self, context, data_processor, monkeypatch + ): + import pandas + + self._use_schema(context, monkeypatch, {"result": "STRING"}) + with pytest.raises(TypeError) as exc_info: + data_processor._set_output_tuple( + iter([{"result": pandas.DataFrame({"x": [1]})}]) + ) + assert "yield the DataFrame itself" in str(exc_info.value) + + def test_missing_column_is_reported_even_with_a_dataframe_in_another_column( + self, context, data_processor, monkeypatch + ): + import pandas + + # The real problem is the missing "count" column, not the DataFrame. + self._use_schema( + context, + monkeypatch, + {"name": "STRING", "count": "INTEGER", "stats": "STRING"}, + ) + with pytest.raises(KeyError) as exc_info: + data_processor._set_output_tuple( + iter([{"name": "a", "stats": pandas.DataFrame({"v": [1]})}]) + ) + message = str(exc_info.value) + assert "'count'" in message and "expected but missing" in message + assert "DataFrame" not in message.split("]")[-1] + + def test_dataframe_hint_names_the_column_and_keeps_the_original_error( + self, context, data_processor, monkeypatch + ): + import pandas + + self._use_schema(context, monkeypatch, {"r": "LARGE_BINARY"}) + with pytest.raises(TypeError) as exc_info: + data_processor._set_output_tuple( + iter([{"r": pandas.DataFrame({"x": [1]})}]) + ) + message = str(exc_info.value) + assert "Unmatched type for field 'r'" in message + assert "column 'r'" in message + + def test_column_hint_mentions_the_source_udf_columns_setting( + self, context, data_processor, monkeypatch + ): + self._use_schema(context, monkeypatch, {"x": "INTEGER"}) + with pytest.raises(KeyError) as exc_info: + data_processor._set_output_tuple(iter([{"y": 1}])) + assert "'Columns'" in str(exc_info.value)
