This is an automated email from the ASF dual-hosted git repository.
dianfu pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/flink.git
The following commit(s) were added to refs/heads/master by this push:
new 5c422565ab1 [FLINK-40437][python] Add iteration and bounded retrieval
to DataFrame API
5c422565ab1 is described below
commit 5c422565ab1fa8a8b0d019aacec405ee44f4d116
Author: Federico Dolce <[email protected]>
AuthorDate: Wed Sep 30 16:27:33 2026 +0200
[FLINK-40437][python] Add iteration and bounded retrieval to DataFrame API
This closes #29381.
---
.../docs/reference/pyflink.dataframe/dataframe.rst | 6 +
flink-python/pyflink/dataframe/__init__.py | 2 +
flink-python/pyflink/dataframe/dataframe.py | 217 ++++++++++++-
flink-python/pyflink/dataframe/iteration.py | 289 +++++++++++++++++
.../pyflink/dataframe/tests/test_dataframe.py | 2 +-
.../pyflink/dataframe/tests/test_iteration.py | 342 +++++++++++++++++++++
flink-python/pyflink/dataframe/validation.py | 56 ++++
7 files changed, 909 insertions(+), 5 deletions(-)
diff --git a/flink-python/docs/reference/pyflink.dataframe/dataframe.rst
b/flink-python/docs/reference/pyflink.dataframe/dataframe.rst
index dbeadf80fe9..46286497076 100644
--- a/flink-python/docs/reference/pyflink.dataframe/dataframe.rst
+++ b/flink-python/docs/reference/pyflink.dataframe/dataframe.rst
@@ -149,6 +149,12 @@ Results
DataFrame.collect
DataFrame.to_table
DataFrame.to_pandas
+ DataFrame.iter_rows
+ DataFrame.iter_batches
+ DataFrame.take
+ DataFrame.take_batch
+ CloseableIterator
+ CloseableIterator.close
Windowing
---------
diff --git a/flink-python/pyflink/dataframe/__init__.py
b/flink-python/pyflink/dataframe/__init__.py
index 0a30d752d19..0e67c1a9e54 100644
--- a/flink-python/pyflink/dataframe/__init__.py
+++ b/flink-python/pyflink/dataframe/__init__.py
@@ -66,11 +66,13 @@ from pyflink.dataframe.dataframe import DataFrame,
GroupedDataFrame, col, lit
from pyflink.dataframe.dataframe_config import config
from pyflink.dataframe.datatype import DataType
from pyflink.dataframe.io import read_catalog_table, read_generic, read_json,
read_parquet
+from pyflink.dataframe.iteration import CloseableIterator
from pyflink.dataframe.sql import sql
from pyflink.dataframe.udf import udf
from pyflink.dataframe.udtf import udtf
__all__ = [
+ "CloseableIterator",
"DataFrame",
"GroupedDataFrame",
"DataType",
diff --git a/flink-python/pyflink/dataframe/dataframe.py
b/flink-python/pyflink/dataframe/dataframe.py
index 7a9679166c8..8698dda1367 100644
--- a/flink-python/pyflink/dataframe/dataframe.py
+++ b/flink-python/pyflink/dataframe/dataframe.py
@@ -25,6 +25,7 @@ from typing import (
Callable,
Dict,
List,
+ Literal,
Optional,
Set,
Tuple,
@@ -36,6 +37,7 @@ from typing import (
if TYPE_CHECKING:
import pandas
+ import pyarrow
from pyflink.dataframe.udf import _DataTypeLike
from pyflink.dataframe.udtf import _DataFrameUDTFWrapper
from pyflink.table.table_environment import TableEnvironment
@@ -43,6 +45,18 @@ if TYPE_CHECKING:
from pyflink.common import Row
from pyflink.dataframe.datatype import _INT_MAX, DataType
+from pyflink.dataframe.iteration import (
+ _BATCH_FORMATS,
+ CloseableIterator,
+ _build_arrow_schema,
+ _iterate_batches,
+ _iterate_rows,
+ _row_to_dict,
+ _rows_to_batch,
+ _take_rows,
+ _validate_row_kind_field,
+)
+from pyflink.dataframe.validation import _require_choice, _require_int,
_require_number
from pyflink.java_gateway import get_gateway
from pyflink.table.expression import Expression, _get_java_expression
from pyflink.table.expressions import (
@@ -64,10 +78,7 @@ T = TypeVar("T")
def _validate_row_count(n: int) -> None:
- if isinstance(n, bool) or not isinstance(n, int):
- raise TypeError("n must be an integer")
- if n < 0:
- raise ValueError("n must be non-negative")
+ _require_int(n, "n", 0)
if n > _INT_MAX:
raise ValueError(f"n must be less than or equal to {_INT_MAX}")
@@ -1720,6 +1731,204 @@ class DataFrame:
with self._table.execute().collect() as rows:
return list(rows)
+ @PublicEvolving()
+ def iter_rows(
+ self,
+ *,
+ include_row_kind: bool = False,
+ row_kind_field: str = "__row_kind__",
+ ) -> CloseableIterator[Dict[str, Any]]:
+ """
+ Execute this DataFrame and iterate over its rows as they arrive, one
dict per row.
+
+ Rows are fetched incrementally, so this also works on unbounded
sources. The job runs until
+ the iterator is exhausted or closed; use it in a ``with`` block.
+
+ On an updating DataFrame, such as a streaming aggregation, every
changelog entry is a
+ separate row. Set ``include_row_kind`` to tell insertions, updates and
deletions apart.
+
+ TIMESTAMP_LTZ columns are not supported yet: the row transfer shared
with :meth:`collect`
+ cannot serialize them.
+
+ :param include_row_kind: Whether to add each row's change kind, one of
``"+I"``,
+ ``"-U"``, ``"+U"`` and ``"-D"``.
+ :param row_kind_field: Key under which the change kind is added. It
must not clash with a
+ column name.
+ :return: An iterator of dicts mapping column names to values.
+
+ Example::
+
+ >>> import pyflink.dataframe as pf
+ >>> df = pf.from_records([{"id": 1}, {"id": 2}])
+ >>> with df.iter_rows() as rows:
+ ... for row in rows:
+ ... print(row["id"])
+
+ .. versionadded:: 2.4.0
+ """
+ columns = self.columns
+ _validate_row_kind_field(columns, include_row_kind, row_kind_field)
+ return _iterate_rows(self._table, columns, row_kind_field if
include_row_kind else None)
+
+ @PublicEvolving()
+ def iter_batches(
+ self,
+ *,
+ batch_size: int = 1000,
+ batch_format: Literal["pandas", "pyarrow"] = "pandas",
+ include_row_kind: bool = False,
+ row_kind_field: str = "__row_kind__",
+ ) -> CloseableIterator[Union["pandas.DataFrame", "pyarrow.Table"]]:
+ """
+ Execute this DataFrame and iterate over its rows in batches of
``batch_size``.
+
+ Every batch except possibly the last holds exactly ``batch_size``
rows, so on a slow
+ unbounded source a batch is only emitted once enough rows have
arrived. The job runs until
+ the iterator is exhausted or closed; use it in a ``with`` block.
+
+ TIMESTAMP_LTZ columns are not supported yet: the row transfer shared
with :meth:`collect`
+ cannot serialize them.
+
+ :param batch_size: Number of rows per batch.
+ :param batch_format: ``"pandas"`` for pandas DataFrames or
``"pyarrow"`` for PyArrow
+ Tables.
+ :param include_row_kind: Whether to add a column with each row's
change kind, one of
+ ``"+I"``, ``"-U"``, ``"+U"`` and ``"-D"``.
+ :param row_kind_field: Name of the change kind column. It must not
clash with a column
+ name.
+ :return: An iterator of batches in the requested format.
+
+ Example::
+
+ >>> import pyflink.dataframe as pf
+ >>> df = pf.range(10)
+ >>> with df.iter_batches(batch_size=4) as batches:
+ ... for batch in batches:
+ ... print(len(batch))
+
+ .. versionadded:: 2.4.0
+ """
+ _require_int(batch_size, "batch_size", 1)
+ _require_choice(batch_format, "batch_format", _BATCH_FORMATS)
+ schema = self._table.get_resolved_schema()
+ columns = schema.get_column_names()
+ data_types = schema.get_column_data_types()
+ _validate_row_kind_field(columns, include_row_kind, row_kind_field)
+ row_kind_field = row_kind_field if include_row_kind else None
+ return _iterate_batches(
+ self._table,
+ batch_size,
+ batch_format,
+ data_types,
+ _build_arrow_schema(columns, data_types, row_kind_field),
+ row_kind_field,
+ )
+
+ @PublicEvolving()
+ def take(
+ self,
+ n: int,
+ *,
+ timeout: Optional[float] = None,
+ include_row_kind: bool = False,
+ row_kind_field: str = "__row_kind__",
+ ) -> List[Dict[str, Any]]:
+ """
+ Execute this DataFrame and return its first ``n`` rows as dicts.
+
+ The job is cancelled once ``n`` rows have arrived if it is still
running, so this is a
+ safe way to peek at an unbounded source. Fewer rows are returned if
the result ends first
+ or ``timeout`` expires.
+ The query itself is not limited: on an updating DataFrame the rows are
the first ``n``
+ changelog entries.
+
+ TIMESTAMP_LTZ columns are not supported yet: the row transfer shared
with :meth:`collect`
+ cannot serialize them.
+
+ :param n: Maximum number of rows to return.
+ :param timeout: Maximum number of seconds to wait for rows once the
job is submitted.
+ ``None`` waits until ``n`` rows arrive or the result ends.
+ :param include_row_kind: Whether to add each row's change kind, one of
``"+I"``,
+ ``"-U"``, ``"+U"`` and ``"-D"``.
+ :param row_kind_field: Key under which the change kind is added. It
must not clash with a
+ column name.
+ :return: Up to ``n`` dicts mapping column names to values.
+
+ Example::
+
+ >>> import pyflink.dataframe as pf
+ >>> df = pf.range(100)
+ >>> df.take(2)
+ [{'id': 0}, {'id': 1}]
+
+ .. versionadded:: 2.4.0
+ """
+ _validate_row_count(n)
+ if timeout is not None:
+ _require_number(timeout, "timeout", 0)
+ columns = self.columns
+ _validate_row_kind_field(columns, include_row_kind, row_kind_field)
+ row_kind_field = row_kind_field if include_row_kind else None
+ return [
+ _row_to_dict(row, columns, row_kind_field)
+ for row in _take_rows(self._table, n, timeout)
+ ]
+
+ @PublicEvolving()
+ def take_batch(
+ self,
+ n: int,
+ *,
+ timeout: Optional[float] = None,
+ batch_format: Literal["pandas", "pyarrow"] = "pandas",
+ include_row_kind: bool = False,
+ row_kind_field: str = "__row_kind__",
+ ) -> Union["pandas.DataFrame", "pyarrow.Table"]:
+ """
+ Execute this DataFrame and return its first ``n`` rows as a single
batch.
+
+ Behaves like :meth:`take` but returns a pandas DataFrame or PyArrow
Table.
+
+ TIMESTAMP_LTZ columns are not supported yet: the row transfer shared
with :meth:`collect`
+ cannot serialize them.
+
+ :param n: Maximum number of rows to return.
+ :param timeout: Maximum number of seconds to wait for rows once the
job is submitted.
+ ``None`` waits until ``n`` rows arrive or the result ends.
+ :param batch_format: ``"pandas"`` for a pandas DataFrame or
``"pyarrow"`` for a PyArrow
+ Table.
+ :param include_row_kind: Whether to add a column with each row's
change kind, one of
+ ``"+I"``, ``"-U"``, ``"+U"`` and ``"-D"``.
+ :param row_kind_field: Name of the change kind column. It must not
clash with a column
+ name.
+ :return: A batch of up to ``n`` rows in the requested format.
+
+ Example::
+
+ >>> import pyflink.dataframe as pf
+ >>> df = pf.range(100)
+ >>> pdf = df.take_batch(5)
+
+ .. versionadded:: 2.4.0
+ """
+ _validate_row_count(n)
+ if timeout is not None:
+ _require_number(timeout, "timeout", 0)
+ _require_choice(batch_format, "batch_format", _BATCH_FORMATS)
+ schema = self._table.get_resolved_schema()
+ columns = schema.get_column_names()
+ data_types = schema.get_column_data_types()
+ _validate_row_kind_field(columns, include_row_kind, row_kind_field)
+ row_kind_field = row_kind_field if include_row_kind else None
+ arrow_schema = _build_arrow_schema(columns, data_types, row_kind_field)
+ return _rows_to_batch(
+ _take_rows(self._table, n, timeout),
+ data_types,
+ arrow_schema,
+ batch_format,
+ row_kind_field,
+ )
+
@PublicEvolving()
def to_table(self) -> Table:
"""
diff --git a/flink-python/pyflink/dataframe/iteration.py
b/flink-python/pyflink/dataframe/iteration.py
new file mode 100644
index 00000000000..53f6fd958c7
--- /dev/null
+++ b/flink-python/pyflink/dataframe/iteration.py
@@ -0,0 +1,289 @@
+################################################################################
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+################################################################################
+
+import itertools
+import queue
+import threading
+import time
+from typing import (
+ Any,
+ Callable,
+ Dict,
+ Iterator,
+ List,
+ Optional,
+ Protocol,
+ TypeVar,
+)
+
+from pyflink.common import Row
+from pyflink.dataframe.validation import _require_non_empty_str
+from pyflink.table.types import ArrayType, DataType, MapType, RowType,
create_arrow_schema
+from pyflink.util.api_stability_decorators import PublicEvolving
+
+__all__ = ["CloseableIterator"]
+
+T = TypeVar("T")
+T_co = TypeVar("T_co", covariant=True)
+
+_BATCH_FORMATS = ("pandas", "pyarrow")
+
+
+@PublicEvolving()
+class CloseableIterator(Iterator[T_co], Protocol):
+ """
+ An iterator over the results of a running Flink job.
+
+ Closing the iterator cancels the job if it is still running. The iterator
closes itself once
+ the results are exhausted or iteration raises an error, so only iterators
abandoned before
+ their end need an explicit :meth:`close`. Prefer a ``with`` block: on an
unbounded source the
+ job keeps running until the iterator is closed.
+
+ Instances are returned by :meth:`DataFrame.iter_rows` and
:meth:`DataFrame.iter_batches`.
+
+ Example::
+
+ >>> import pyflink.dataframe as pf
+ >>> df = pf.from_records([{"id": 1}, {"id": 2}])
+ >>> with df.iter_rows() as rows:
+ ... first = next(rows)
+
+ .. versionadded:: 2.4.0
+ """
+
+ def close(self) -> None:
+ """
+ Close the iterator and cancel the job if it is still running. Closing
twice is a no-op.
+ """
+ ...
+
+ def __enter__(self) -> "CloseableIterator[T_co]":
+ ...
+
+ def __exit__(self, exc_type, exc_value, traceback) -> None:
+ ...
+
+
+class _CloseableIterator(Iterator[T]):
+ def __init__(self, iterator: Iterator[T], close: Callable[[], None]):
+ self._iterator = iterator
+ self._close = close
+ self._closed = False
+
+ def __iter__(self) -> "_CloseableIterator[T]":
+ return self
+
+ def __next__(self) -> T:
+ if self._closed:
+ raise StopIteration
+ try:
+ return next(self._iterator)
+ except BaseException:
+ # Covers StopIteration and interrupts too: either way the job is
no longer needed.
+ self.close()
+ raise
+
+ def close(self) -> None:
+ if not self._closed:
+ self._closed = True
+ self._close()
+
+ def __enter__(self) -> "_CloseableIterator[T]":
+ return self
+
+ def __exit__(self, exc_type, exc_value, traceback) -> None:
+ self.close()
+
+
+def _validate_row_kind_field(
+ columns: List[str], include_row_kind: bool, row_kind_field: str
+) -> None:
+ if not include_row_kind:
+ return
+ _require_non_empty_str(row_kind_field, "row_kind_field")
+ if row_kind_field in columns:
+ raise ValueError(
+ f"row_kind_field '{row_kind_field}' conflicts with an existing
column; "
+ "pass a different row_kind_field"
+ )
+
+
+def _row_to_dict(
+ row: Row, columns: List[str], row_kind_field: Optional[str]
+) -> Dict[str, Any]:
+ result = dict(zip(columns, row))
+ if row_kind_field is not None:
+ result[row_kind_field] = str(row.get_row_kind())
+ return result
+
+
+def _iterate_rows(
+ table, columns: List[str], row_kind_field: Optional[str]
+) -> _CloseableIterator[Dict[str, Any]]:
+ rows = table.execute().collect()
+ return _CloseableIterator(
+ (_row_to_dict(row, columns, row_kind_field) for row in rows),
rows.close
+ )
+
+
+def _build_arrow_schema(
+ columns: List[str], data_types: List[DataType], row_kind_field:
Optional[str]
+) -> Any:
+ """
+ Build the Arrow schema of a batch, failing on unsupported column types
before any job runs.
+ """
+ import pyarrow as pa
+
+ schema = create_arrow_schema(columns, data_types, allow_nested=True)
+ if row_kind_field is not None:
+ schema = schema.append(pa.field(row_kind_field, pa.utf8(),
nullable=False))
+ return schema
+
+
+def _iterate_batches(
+ table,
+ batch_size: int,
+ batch_format: str,
+ data_types: List[DataType],
+ arrow_schema: Any,
+ row_kind_field: Optional[str],
+) -> _CloseableIterator[Any]:
+ rows = table.execute().collect()
+
+ def batches():
+ while True:
+ chunk = list(itertools.islice(rows, batch_size))
+ if not chunk:
+ return
+ yield _rows_to_batch(chunk, data_types, arrow_schema,
batch_format, row_kind_field)
+
+ return _CloseableIterator(batches(), rows.close)
+
+
+def _take_rows(table, n: int, timeout: Optional[float]) -> List[Row]:
+ if n == 0:
+ return []
+ rows = table.execute().collect()
+ if timeout is None:
+ with rows:
+ return list(itertools.islice(rows, n))
+ return _take_rows_until(rows, n, time.monotonic() + timeout)
+
+
+def _take_rows_until(rows, n: int, deadline: float) -> List[Row]:
+ """
+ Read up to ``n`` rows, returning early with what has arrived when
``deadline`` passes.
+
+ Fetching a row blocks inside the JVM and cannot be interrupted from
Python, so a worker
+ thread fetches while this thread waits on a queue with a timeout. Closing
the iterator
+ cancels the job; the JVM fetcher polls the job status between fetch
attempts, so the worker
+ unblocks within one retry interval, either with an end-of-results or with
an error. The
+ worker is a daemon thread and holds its own gateway connection, so it
cannot stall exit if
+ the cluster never answers.
+ """
+ results: "queue.Queue" = queue.Queue()
+ closed = threading.Event()
+
+ def fetch():
+ try:
+ for row in itertools.islice(rows, n):
+ if closed.is_set():
+ return
+ results.put(("row", row))
+ if not closed.is_set():
+ results.put(("end", None))
+ except BaseException as error:
+ # After close() the job is being cancelled, so a failing fetch is
the expected way
+ # for the worker to unblock and nobody is waiting for its result
any more. The
+ # caller sets `closed` before calling close(), so an error caused
by the
+ # cancellation is never mistaken for a genuine fetch failure.
+ if not closed.is_set():
+ results.put(("error", error))
+
+ taken: List[Row] = []
+ try:
+ threading.Thread(target=fetch, name="dataframe-take",
daemon=True).start()
+ while len(taken) < n:
+ remaining = deadline - time.monotonic()
+ if remaining <= 0:
+ break
+ try:
+ kind, value = results.get(timeout=remaining)
+ except queue.Empty:
+ break
+ if kind == "row":
+ taken.append(value)
+ elif kind == "end":
+ break
+ else:
+ raise value
+ finally:
+ closed.set()
+ rows.close()
+ return taken
+
+
+def _rows_to_batch(
+ rows: List[Row],
+ data_types: List[DataType],
+ arrow_schema: Any,
+ batch_format: str,
+ row_kind_field: Optional[str],
+) -> Any:
+ import pyarrow as pa
+
+ arrays = [
+ pa.array(
+ [_to_arrow_value(row[index], data_type) for row in rows],
+ type=arrow_schema.field(index).type,
+ )
+ for index, data_type in enumerate(data_types)
+ ]
+ if row_kind_field is not None:
+ arrays.append(pa.array([str(row.get_row_kind()) for row in rows],
type=pa.utf8()))
+ batch = pa.Table.from_arrays(arrays, schema=arrow_schema)
+ return batch.to_pandas() if batch_format == "pandas" else batch
+
+
+def _to_arrow_value(value: Any, data_type: DataType) -> Any:
+ """
+ Reshape a collected value into what pyarrow expects for ``data_type``.
+
+ Collected nested rows are positional, while pyarrow builds structs from
dicts keyed by
+ field name. Maps become key-value pair lists, which every supported
pyarrow version accepts.
+ """
+ if value is None:
+ return None
+ if isinstance(data_type, RowType):
+ return {
+ name: _to_arrow_value(field_value, field_type)
+ for name, field_value, field_type in zip(
+ data_type.field_names(), value, data_type.field_types()
+ )
+ }
+ if isinstance(data_type, ArrayType):
+ return [_to_arrow_value(element, data_type.element_type) for element
in value]
+ if isinstance(data_type, MapType):
+ return [
+ (
+ _to_arrow_value(key, data_type.key_type),
+ _to_arrow_value(item, data_type.value_type),
+ )
+ for key, item in value.items()
+ ]
+ return value
diff --git a/flink-python/pyflink/dataframe/tests/test_dataframe.py
b/flink-python/pyflink/dataframe/tests/test_dataframe.py
index 0716d2cc16c..b2833cfd8b5 100644
--- a/flink-python/pyflink/dataframe/tests/test_dataframe.py
+++ b/flink-python/pyflink/dataframe/tests/test_dataframe.py
@@ -232,7 +232,7 @@ class DataFrameSlicingTests(unittest.TestCase):
def test_rejects_negative_values(self):
for method_name in ("limit", "offset", "head"):
with self.subTest(method=method_name):
- with self.assertRaisesRegex(ValueError, "n must be
non-negative"):
+ with self.assertRaisesRegex(ValueError, "n must be at least
0"):
getattr(self.dataframe, method_name)(-1)
self.table.fetch.assert_not_called()
diff --git a/flink-python/pyflink/dataframe/tests/test_iteration.py
b/flink-python/pyflink/dataframe/tests/test_iteration.py
new file mode 100644
index 00000000000..9753b38cc59
--- /dev/null
+++ b/flink-python/pyflink/dataframe/tests/test_iteration.py
@@ -0,0 +1,342 @@
+################################################################################
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+################################################################################
+
+import time
+import unittest
+
+import pandas as pd
+import pyarrow as pa
+from py4j.protocol import Py4JJavaError
+
+import pyflink.dataframe as pf
+from pyflink.common import Row, RowKind
+from pyflink.dataframe import DataType
+from pyflink.dataframe.iteration import _build_arrow_schema,
_CloseableIterator, _rows_to_batch
+from pyflink.table import DataTypes
+from pyflink.testing.test_case_utils import PyFlinkStreamDataFrameTestCase
+
+
+def _row(*values, kind=RowKind.INSERT):
+ row = Row(*values)
+ row.set_row_kind(kind)
+ return row
+
+
+class CloseableIteratorTests(unittest.TestCase):
+ def test_closes_once_exhausted(self):
+ closes = []
+ iterator = _CloseableIterator(iter([1, 2]), lambda:
closes.append(True))
+
+ self.assertEqual(list(iterator), [1, 2])
+ self.assertEqual(closes, [True])
+
+ def test_closes_and_reraises_when_iteration_fails(self):
+ def failing():
+ yield 1
+ raise RuntimeError("fetch failed")
+
+ closes = []
+ iterator = _CloseableIterator(failing(), lambda: closes.append(True))
+
+ self.assertEqual(next(iterator), 1)
+ with self.assertRaisesRegex(RuntimeError, "fetch failed"):
+ next(iterator)
+ self.assertEqual(closes, [True])
+
+ def test_context_manager_closes_early_and_close_is_idempotent(self):
+ closes = []
+ with _CloseableIterator(iter([1, 2, 3]), lambda: closes.append(True))
as iterator:
+ self.assertEqual(next(iterator), 1)
+ iterator.close()
+
+ self.assertEqual(closes, [True])
+ self.assertEqual(list(iterator), [])
+
+ def test_row_kind_strings_match_the_documented_short_forms(self):
+ # The docstrings promise these exact strings; they come from
RowKind.__str__.
+ self.assertEqual([str(kind) for kind in RowKind], ["+I", "-U", "+U",
"-D"])
+
+
+class RowsToBatchTests(unittest.TestCase):
+ _COLUMNS = ["id", "name"]
+ _TYPES = [DataTypes.BIGINT(), DataTypes.STRING()]
+
+ def _batch(self, rows, batch_format="pyarrow", row_kind_field=None,
+ columns=None, data_types=None):
+ columns = columns or self._COLUMNS
+ data_types = data_types or self._TYPES
+ schema = _build_arrow_schema(columns, data_types, row_kind_field)
+ return _rows_to_batch(rows, data_types, schema, batch_format,
row_kind_field)
+
+ def test_arrow_batch_uses_the_flink_schema(self):
+ batch = self._batch([_row(1, "Alice")])
+
+ self.assertIsInstance(batch, pa.Table)
+ self.assertEqual(batch.schema.types, [pa.int64(), pa.utf8()])
+ self.assertEqual(batch.to_pylist(), [{"id": 1, "name": "Alice"}])
+
+ def test_pandas_batch_with_row_kind_column(self):
+ batch = self._batch(
+ [_row(1, "Alice"), _row(1, "Alice", kind=RowKind.DELETE)],
+ batch_format="pandas",
+ row_kind_field="op",
+ )
+
+ self.assertIsInstance(batch, pd.DataFrame)
+ self.assertEqual(list(batch.columns), ["id", "name", "op"])
+ self.assertEqual(list(batch["op"]), ["+I", "-D"])
+
+ def test_empty_batch_keeps_the_schema(self):
+ batch = self._batch([], row_kind_field="op")
+
+ self.assertEqual(batch.schema.names, ["id", "name", "op"])
+ self.assertEqual(batch.num_rows, 0)
+
+ def test_nested_values_become_arrow_structs_lists_and_maps(self):
+ batch = self._batch(
+ [_row(Row(1, ["a"]), {"k": 2}), _row(None, None)],
+ columns=["point", "counts"],
+ data_types=[
+ DataTypes.ROW([
+ DataTypes.FIELD("x", DataTypes.INT()),
+ DataTypes.FIELD("tags",
DataTypes.ARRAY(DataTypes.STRING())),
+ ]),
+ DataTypes.MAP(DataTypes.STRING(), DataTypes.BIGINT()),
+ ],
+ )
+
+ self.assertEqual(
+ batch.to_pylist(),
+ [
+ {"point": {"x": 1, "tags": ["a"]}, "counts": [("k", 2)]},
+ {"point": None, "counts": None},
+ ],
+ )
+
+
+class IterationTests(PyFlinkStreamDataFrameTestCase):
+ def _people(self):
+ return pf.from_records(
+ [(i, f"name-{i}") for i in range(5)], schema=["id", "name"]
+ )
+
+ def _unbounded_source(self, rows_per_second):
+ return pf.read_generic(
+ "datagen",
+ schema={"x": DataType.int64()},
+ options={"rows-per-second": str(rows_per_second)},
+ )
+
+ def _failing_source(self):
+ # Random datagen strings are not numbers, so the cast fails as soon as
a row flows.
+ self.t_env.execute_sql(
+ "CREATE TEMPORARY TABLE IF NOT EXISTS random_strings (s STRING) "
+ "WITH ('connector' = 'datagen', 'rows-per-second' = '10')"
+ )
+ return pf.from_table(
+ self.t_env.sql_query("SELECT CAST(s AS INT) AS x FROM
random_strings")
+ )
+
+ def _job_count(self):
+ return len(self.resource.getMiniCluster().listJobs().get())
+
+ def _assert_all_jobs_terminate(self, timeout_seconds=60):
+ mini_cluster = self.resource.getMiniCluster()
+ deadline = time.monotonic() + timeout_seconds
+ while True:
+ running = [
+ job for job in mini_cluster.listJobs().get()
+ if not job.getJobState().isGloballyTerminalState()
+ ]
+ if not running:
+ return
+ if time.monotonic() > deadline:
+ self.fail(f"jobs still running: {[str(job.getJobId()) for job
in running]}")
+ time.sleep(0.1)
+
+ # ------------------------------------------------------------------
iter_rows
+
+ def test_iter_rows_yields_dicts_keyed_by_column(self):
+ with self._people().iter_rows() as rows:
+ collected = sorted(rows, key=lambda row: row["id"])
+
+ self.assertEqual(collected, [{"id": i, "name": f"name-{i}"} for i in
range(5)])
+
+ def test_iter_rows_row_kind_field_is_only_checked_when_included(self):
+ with self._people().iter_rows(row_kind_field="name") as rows:
+ self.assertEqual(len(list(rows)), 5)
+
+ def test_iter_rows_reraises_when_the_job_fails(self):
+ with self._failing_source().iter_rows() as rows:
+ with self.assertRaises(Py4JJavaError):
+ list(rows)
+ self._assert_all_jobs_terminate()
+
+ def test_closing_iter_rows_early_cancels_an_unbounded_job(self):
+ with self._unbounded_source(100).iter_rows() as rows:
+ next(rows)
+
+ self._assert_all_jobs_terminate()
+
+ # ------------------------------------------------------------------
iter_batches
+
+ def test_iter_batches_match_to_pandas(self):
+ dataframe = pf.from_records(
+ [(i, f"name-{i}", i * 1.5) for i in range(7)], schema=["id",
"name", "score"]
+ )
+
+ with dataframe.iter_batches(batch_size=3) as batches:
+ batches = list(batches)
+
+ self.assertEqual([len(batch) for batch in batches], [3, 3, 1])
+ pd.testing.assert_frame_equal(
+ pd.concat(batches).sort_values("id").reset_index(drop=True),
+ dataframe.to_pandas().sort_values("id").reset_index(drop=True),
+ )
+
+ def test_iter_batches_in_pyarrow_format(self):
+ with self._people().iter_batches(batch_format="pyarrow") as batches:
+ (batch,) = batches
+
+ self.assertIsInstance(batch, pa.Table)
+ self.assertEqual(batch.num_rows, 5)
+
+ def test_iter_batches_reraises_when_the_job_fails(self):
+ with self._failing_source().iter_batches(batch_size=1) as batches:
+ with self.assertRaises(Py4JJavaError):
+ next(batches)
+ self._assert_all_jobs_terminate()
+
+ # ------------------------------------------------------------------ take
/ take_batch
+
+ def test_take_returns_first_n_rows(self):
+ taken = self._people().take(2)
+
+ self.assertEqual(len(taken), 2)
+ self.assertTrue(all(row.keys() == {"id", "name"} for row in taken))
+
+ def test_take_returns_all_rows_when_result_is_shorter(self):
+ self.assertEqual(len(self._people().take(10)), 5)
+ self.assertEqual(len(self._people().take(10, timeout=60)), 5)
+
+ def test_take_zero_rows_does_not_execute(self):
+ jobs_before = self._job_count()
+
+ self.assertEqual(self._people().take(0), [])
+ self.assertEqual(self._job_count(), jobs_before)
+
+ def test_row_kinds_of_an_updating_result(self):
+ dataframe = pf.from_records([(1, "a"), (2, "a")], schema=["id",
"name"])
+ counts = dataframe.group_by("name").agg(c=pf.col("id").count)
+
+ self.assertEqual(
+ counts.take(10, include_row_kind=True, row_kind_field="op"),
+ [
+ {"name": "a", "c": 1, "op": "+I"},
+ {"name": "a", "c": 1, "op": "-U"},
+ {"name": "a", "c": 2, "op": "+U"},
+ ],
+ )
+
+ def test_take_cancels_an_unbounded_job(self):
+ self.assertEqual(len(self._unbounded_source(100).take(3)), 3)
+
+ self._assert_all_jobs_terminate()
+
+ def
test_take_with_timeout_returns_partial_rows_from_an_unbounded_job(self):
+ rows = self._unbounded_source(1).take(1000, timeout=2)
+
+ self.assertLess(len(rows), 1000)
+ self._assert_all_jobs_terminate()
+
+ def test_take_reraises_when_the_job_fails(self):
+ with self.assertRaises(Py4JJavaError):
+ self._failing_source().take(5)
+ with self.assertRaises(Py4JJavaError):
+ self._failing_source().take(5, timeout=60)
+ self._assert_all_jobs_terminate()
+
+ def test_take_batch_returns_first_n_rows_as_one_batch(self):
+ batch = self._people().take_batch(3)
+
+ self.assertIsInstance(batch, pd.DataFrame)
+ self.assertEqual(len(batch), 3)
+
+ def
test_take_batch_with_timeout_returns_partial_rows_from_an_unbounded_job(self):
+ batch = self._unbounded_source(1).take_batch(
+ 1000, timeout=2, batch_format="pyarrow", include_row_kind=True
+ )
+
+ self.assertLess(batch.num_rows, 1000)
+ self.assertEqual(batch.schema.names, ["x", "__row_kind__"])
+ self._assert_all_jobs_terminate()
+
+ def test_take_batch_with_nested_types(self):
+ dataframe = pf.from_table(self.t_env.sql_query(
+ "SELECT ROW(1, 'a') AS point, ARRAY[1, 2] AS items, MAP['k', 3] AS
counts"
+ ))
+
+ batch = dataframe.take_batch(1, batch_format="pyarrow")
+
+ self.assertEqual(
+ batch.to_pylist(),
+ [{"point": {"EXPR$0": 1, "EXPR$1": "a"}, "items": [1, 2],
"counts": [("k", 3)]}],
+ )
+
+ # ------------------------------------------------------------------
argument checks
+
+ def test_invalid_arguments_are_rejected_before_executing(self):
+ dataframe = self._people()
+ multiset = pf.from_table(
+ self.t_env.sql_query("SELECT COLLECT(name) AS tags FROM (VALUES
('a')) AS t(name)")
+ )
+ cases = [
+ (ValueError, "conflicts", lambda: dataframe.iter_rows(
+ include_row_kind=True, row_kind_field="name")),
+ (ValueError, "row_kind_field", lambda: dataframe.iter_rows(
+ include_row_kind=True, row_kind_field="")),
+ (TypeError, "row_kind_field", lambda: dataframe.take(
+ 1, include_row_kind=True, row_kind_field=1)),
+ (ValueError, "batch_size", lambda:
dataframe.iter_batches(batch_size=0)),
+ (TypeError, "batch_size", lambda:
dataframe.iter_batches(batch_size=True)),
+ (ValueError, "batch_format", lambda:
dataframe.iter_batches(batch_format="arrow")),
+ (ValueError, "batch_format", lambda: dataframe.take_batch(1,
batch_format="polars")),
+ (ValueError, "n must be at least 0", lambda: dataframe.take(-1)),
+ (TypeError, "n must be an integer", lambda: dataframe.take(1.5)),
+ (TypeError, "n must be an integer", lambda: dataframe.take(True)),
+ (ValueError, "timeout", lambda: dataframe.take(1, timeout=-1)),
+ (ValueError, "timeout must be finite", lambda: dataframe.take(
+ 1, timeout=float("inf"))),
+ (ValueError, "timeout must be finite", lambda:
dataframe.take_batch(
+ 1, timeout=float("nan"))),
+ (TypeError, "timeout", lambda: dataframe.take(1, timeout="1")),
+ (TypeError, "timeout", lambda: dataframe.take_batch(1,
timeout=True)),
+ (ValueError, "not supported", lambda: multiset.iter_batches()),
+ (ValueError, "not supported", lambda: multiset.take_batch(1)),
+ ]
+ jobs_before = self._job_count()
+
+ for error, message, call in cases:
+ with self.subTest(message=message):
+ with self.assertRaisesRegex(error, message):
+ call()
+ self.assertEqual(self._job_count(), jobs_before)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/flink-python/pyflink/dataframe/validation.py
b/flink-python/pyflink/dataframe/validation.py
new file mode 100644
index 00000000000..49cc95d03aa
--- /dev/null
+++ b/flink-python/pyflink/dataframe/validation.py
@@ -0,0 +1,56 @@
+################################################################################
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+################################################################################
+
+"""
+Argument checks shared by the DataFrame API.
+
+A wrong type raises :class:`TypeError` and a wrong value of the right type
raises
+:class:`ValueError`. ``bool`` is rejected wherever a number is expected, since
it is an ``int``
+subclass and ``take(True)`` is almost certainly a mistake.
+"""
+
+import math
+from typing import Any, Sequence
+
+
+def _require_int(value: Any, name: str, minimum: int) -> None:
+ if isinstance(value, bool) or not isinstance(value, int):
+ raise TypeError(f"{name} must be an integer")
+ if value < minimum:
+ raise ValueError(f"{name} must be at least {minimum}, got {value}")
+
+
+def _require_number(value: Any, name: str, minimum: float) -> None:
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
+ raise TypeError(f"{name} must be a number")
+ if not math.isfinite(value):
+ raise ValueError(f"{name} must be finite, got {value}")
+ if value < minimum:
+ raise ValueError(f"{name} must be at least {minimum}, got {value}")
+
+
+def _require_non_empty_str(value: Any, name: str) -> None:
+ if not isinstance(value, str):
+ raise TypeError(f"{name} must be a string")
+ if not value:
+ raise ValueError(f"{name} must not be empty")
+
+
+def _require_choice(value: Any, name: str, choices: Sequence[str]) -> None:
+ if value not in choices:
+ raise ValueError(f"{name} must be one of {list(choices)}, got
{value!r}")