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 8161afa99bb [FLINK-40434][python] Add flat_map support to the 
DataFrame API
8161afa99bb is described below

commit 8161afa99bb4ede160cf3efdbb4d9d38e7c9d666
Author: victor <[email protected]>
AuthorDate: Fri Sep 18 12:32:24 2026 +0800

    [FLINK-40434][python] Add flat_map support to the DataFrame API
    
    This closes #29234.
---
 .../docs/reference/pyflink.dataframe/dataframe.rst |   1 +
 .../docs/reference/pyflink.dataframe/udf.rst       |  22 +-
 flink-python/pyflink/dataframe/__init__.py         |   2 +
 flink-python/pyflink/dataframe/dataframe.py        |  88 +++
 flink-python/pyflink/dataframe/sql.py              |  44 +-
 flink-python/pyflink/dataframe/tests/test_sql.py   |  94 +++
 flink-python/pyflink/dataframe/tests/test_udtf.py  | 708 +++++++++++++++++++++
 flink-python/pyflink/dataframe/udtf.py             | 518 +++++++++++++++
 8 files changed, 1460 insertions(+), 17 deletions(-)

diff --git a/flink-python/docs/reference/pyflink.dataframe/dataframe.rst 
b/flink-python/docs/reference/pyflink.dataframe/dataframe.rst
index ed02294ce9c..9c65ebdef80 100644
--- a/flink-python/docs/reference/pyflink.dataframe/dataframe.rst
+++ b/flink-python/docs/reference/pyflink.dataframe/dataframe.rst
@@ -74,6 +74,7 @@ Transformations
     DataFrame.limit
     DataFrame.offset
     DataFrame.head
+    DataFrame.flat_map
     DataFrame.__getitem__
     DataFrame.__getattr__
 
diff --git a/flink-python/docs/reference/pyflink.dataframe/udf.rst 
b/flink-python/docs/reference/pyflink.dataframe/udf.rst
index c0084bee45d..798297eea33 100644
--- a/flink-python/docs/reference/pyflink.dataframe/udf.rst
+++ b/flink-python/docs/reference/pyflink.dataframe/udf.rst
@@ -16,9 +16,12 @@
     limitations under the License.
    
################################################################################
 
-=============================
-User-Defined Scalar Functions
-=============================
+======================
+User-Defined Functions
+======================
+
+Scalar Functions
+================
 
 Use :func:`pyflink.dataframe.udf` to apply Python code to one or more DataFrame
 columns. A scalar UDF produces one logical output column and can be used in
@@ -30,6 +33,18 @@ DataFrame scalar UDFs support general synchronous and 
asynchronous callables,
 and synchronous pandas or Arrow vectorized callables. See 
:func:`pyflink.dataframe.udf`
 for declaration forms, type inference, execution modes, and examples.
 
+Table Functions
+===============
+
+Use :func:`pyflink.dataframe.udtf` to declare a Python function that emits 
zero or
+more rows per invocation. A table UDF can be used with
+:meth:`~pyflink.dataframe.DataFrame.flat_map`, which returns only the emitted 
columns.
+
+DataFrame table UDFs support synchronous functions, callable classes and 
instances, and
+``TableFunction`` instances or classes. See :func:`pyflink.dataframe.udtf` for
+declaration forms, type inference, lifecycle, and examples, and
+:meth:`~pyflink.dataframe.DataFrame.flat_map` for row input and output 
semantics.
+
 API Reference
 =============
 
@@ -39,3 +54,4 @@ API Reference
     :toctree: api/
 
     udf
+    udtf
diff --git a/flink-python/pyflink/dataframe/__init__.py 
b/flink-python/pyflink/dataframe/__init__.py
index c8ed1cf08ae..bce542f48c2 100644
--- a/flink-python/pyflink/dataframe/__init__.py
+++ b/flink-python/pyflink/dataframe/__init__.py
@@ -57,6 +57,7 @@ from pyflink.dataframe.datatype import DataType
 from pyflink.dataframe.io import read_generic
 from pyflink.dataframe.sql import sql
 from pyflink.dataframe.udf import udf
+from pyflink.dataframe.udtf import udtf
 
 __all__ = [
     "DataFrame",
@@ -65,6 +66,7 @@ __all__ = [
     "col",
     "lit",
     "udf",
+    "udtf",
     "from_arrow",
     "from_dict",
     "from_pandas",
diff --git a/flink-python/pyflink/dataframe/dataframe.py 
b/flink-python/pyflink/dataframe/dataframe.py
index bdfacc22480..0341b1c8f8c 100644
--- a/flink-python/pyflink/dataframe/dataframe.py
+++ b/flink-python/pyflink/dataframe/dataframe.py
@@ -27,6 +27,7 @@ from typing import (
     Optional,
     Set,
     Tuple,
+    Type,
     TypeVar,
     Union,
     overload,
@@ -34,6 +35,8 @@ from typing import (
 
 if TYPE_CHECKING:
     import pandas
+    from pyflink.dataframe.udf import _DataTypeLike
+    from pyflink.dataframe.udtf import _DataFrameUDTFWrapper
     from pyflink.table.table_schema import TableSchema
 
 from pyflink.common import Row
@@ -623,6 +626,91 @@ class DataFrame:
     distinct = drop_duplicates
     unique = drop_duplicates
 
+    @PublicEvolving()
+    def flat_map(
+        self,
+        func: Union[Callable[[Dict[str, Any]], Any], Type, 
"_DataFrameUDTFWrapper"],
+        *,
+        return_dtype: Optional["_DataTypeLike"] = None,
+    ) -> "DataFrame":
+        """
+        Apply a function to each row, emitting zero or more output rows.
+
+        The function receives a dictionary keyed by column name, including when
+        declared with :func:`pyflink.dataframe.udtf`.
+        Output column names come from a ``TypedDict`` or an explicit named 
struct;
+        scalar outputs use ``f0``. Multi-field outputs require named fields.
+
+        :param func: Row-based callable, a callable class with a zero-argument 
constructor,
+                     or a declaration created with ``pf.udtf``. Callable 
classes are
+                     instantiated on workers.
+        :param return_dtype: Emitted row type, inferred from annotations when 
omitted.
+                             Required if inference is not possible; must be 
omitted
+                             for a UDTF declaration.
+        :return: A DataFrame containing only the emitted output columns.
+
+        Example::
+
+            >>> from typing import Any, Dict, Iterator, TypedDict
+            >>> import pyflink.dataframe as pf
+            >>> class Token(TypedDict):
+            ...     word: str
+            >>> def split(record: Dict[str, Any]) -> Iterator[Token]:
+            ...     for word in record["text"].split():
+            ...         yield {"word": word}
+            >>> df = pf.from_dict({"text": ["hello world", "flink"]})
+            >>> result = df.flat_map(split)
+            >>> result.columns
+            ['word']
+
+        The decorator is optional for plain callables. Use it to attach 
reusable
+        metadata, such as the output schema, instead of repeating it in each
+        ``flat_map`` call::
+
+            >>> @pf.udtf(return_dtype="ROW<word STRING>")
+            ... def tokenize(record: Dict[str, Any]):
+            ...     yield from record["text"].split()
+            >>> result = df.flat_map(tokenize)
+            >>> result.columns
+            ['word']
+
+        An explicit output type can be supplied for unannotated callables::
+
+            >>> words = df.flat_map(lambda record: record["text"].split(), 
return_dtype=str)
+            >>> words.columns
+            ['f0']
+            >>> named = df.flat_map(
+            ...     lambda record: record["text"].split(),
+            ...     return_dtype="ROW<word STRING>")
+            >>> named.columns
+            ['word']
+
+        Callable classes can be passed directly and are instantiated on 
workers::
+
+            >>> class SplitWords:
+            ...     def __call__(self, record: Dict[str, Any]) -> 
Iterator[str]:
+            ...         yield from record["text"].split()
+            >>> words = df.flat_map(SplitWords)
+
+        ``TableFunction`` classes are declared with 
:func:`pyflink.dataframe.udtf`::
+
+            >>> from pyflink.table.udf import TableFunction
+            >>> class SplitWordsFunction(TableFunction):
+            ...     def eval(self, record: Dict[str, Any]) -> Iterator[str]:
+            ...         yield from record["text"].split()
+            >>> words = df.flat_map(pf.udtf(SplitWordsFunction))
+
+        See :func:`pyflink.dataframe.udtf` for more details.
+
+        .. versionadded:: 2.4.0
+        """
+        from pyflink.dataframe.udtf import _resolve_flat_map_udtf
+
+        expression, output_columns = _resolve_flat_map_udtf(func, 
return_dtype, self.columns)
+        table = self._table.flat_map(expression)
+        # Table UDTFs expose positional field names, so restore the declared 
names.
+        return DataFrame(table.alias(output_columns[0], *output_columns[1:]))
+
     # ======================== Filtering & Ordering ========================
 
     @PublicEvolving()
diff --git a/flink-python/pyflink/dataframe/sql.py 
b/flink-python/pyflink/dataframe/sql.py
index 879a060b29c..9a9e11e2134 100644
--- a/flink-python/pyflink/dataframe/sql.py
+++ b/flink-python/pyflink/dataframe/sql.py
@@ -26,9 +26,11 @@ from py4j.protocol import Py4JJavaError
 from pyflink.dataframe.context import get_or_create_table_environment
 from pyflink.dataframe.dataframe import DataFrame
 from pyflink.dataframe.udf import _DataFrameUDFWrapper
+from pyflink.dataframe.udtf import _DataFrameUDTFWrapper
 from pyflink.java_gateway import get_gateway
 from pyflink.table import Table, TableEnvironment
 from pyflink.table.expression import Expression
+from pyflink.table.udf import UserDefinedFunctionWrapper
 from pyflink.util.api_stability_decorators import PublicEvolving
 from pyflink.util.java_utils import is_instance_of
 
@@ -36,11 +38,13 @@ __all__ = ["sql"]
 
 _LOG = logging.getLogger(__name__)
 
-# UDFs are annotated with the declared return type of 
:func:`pyflink.dataframe.udf`
+# Scalar UDFs use the declared return type of :func:`pyflink.dataframe.udf`
 # so that its result type-checks as a binding; at runtime a binding must be an 
actual
 # UDF object, which is what _BINDABLE_TYPES enforces.
-_Binding = Union[DataFrame, Callable[..., Expression]]
-_BINDABLE_TYPES = (DataFrame, _DataFrameUDFWrapper)
+_FunctionBinding = Union[_DataFrameUDFWrapper, _DataFrameUDTFWrapper]
+_Binding = Union[DataFrame, Callable[..., Expression], _DataFrameUDTFWrapper]
+_FUNCTION_TYPES = (_DataFrameUDFWrapper, _DataFrameUDTFWrapper)
+_BINDABLE_TYPES = (DataFrame, *_FUNCTION_TYPES)
 
 
 @PublicEvolving()
@@ -51,9 +55,9 @@ def sql(query: str, *, auto_bind: bool = True, **bindings: 
_Binding) -> DataFram
     The query must be a single statement that returns a result, such as SELECT 
or
     VALUES (no INSERT / DDL; use :meth:`TableEnvironment.execute_sql` for 
those).
     The referenced DataFrames are registered as temporary views and the 
referenced
-    UDFs (created with :func:`~pyflink.dataframe.udf`) as temporary system 
functions
-    for the duration of the call, and both are dropped afterwards. The result 
can be
-    further transformed with the DataFrame API.
+    UDFs (created with :func:`~pyflink.dataframe.udf` or 
:func:`~pyflink.dataframe.udtf`)
+    as temporary system functions for the duration of the call, and both are 
dropped
+    afterwards. The result can be further transformed with the DataFrame API.
 
     When ``auto_bind`` is ``True`` (the default), the caller's local and 
global variables
     are scanned for :class:`DataFrame` and UDF objects and each is registered 
under its
@@ -91,7 +95,8 @@ def sql(query: str, *, auto_bind: bool = True, **bindings: 
_Binding) -> DataFram
                         auto-bound candidates belong to different 
TableEnvironments
                         when there are no explicit bindings.
     :raises TypeError: If an explicit binding is neither a :class:`DataFrame` 
nor a
-                       UDF created with :func:`~pyflink.dataframe.udf`.
+                       UDF created with :func:`~pyflink.dataframe.udf` or
+                       :func:`~pyflink.dataframe.udtf`.
 
     Example::
 
@@ -112,6 +117,11 @@ def sql(query: str, *, auto_bind: bool = True, **bindings: 
_Binding) -> DataFram
         ...     return value + 1
         >>> pf.sql("SELECT add_one(a) AS a1 FROM df1")
         >>> pf.sql("SELECT inc(a) FROM src", auto_bind=False, src=df1, 
inc=add_one)
+        >>> # Table UDFs can be used in lateral joins
+        >>> @pf.udtf(return_dtype=str)
+        ... def chars(text):
+        ...     yield from text
+        >>> pf.sql("SELECT ch FROM df1, LATERAL TABLE(chars(b)) AS T(ch)")
         >>> # Mix SQL and the DataFrame API
         >>> pf.sql("SELECT a, b FROM df1").filter(pf.col("a") > 1).to_pandas()
 
@@ -138,7 +148,7 @@ def sql(query: str, *, auto_bind: bool = True, **bindings: 
_Binding) -> DataFram
         if not isinstance(value, _BINDABLE_TYPES):
             raise TypeError(
                 f"sql() binding '{name}' must be a DataFrame or a UDF created 
with "
-                f"pyflink.dataframe.udf, got {type(value).__name__}"
+                f"pyflink.dataframe.udf or pyflink.dataframe.udtf, got 
{type(value).__name__}"
             )
     explicit_frames = _get_dataframes(bindings)
     explicit_udfs = _get_udfs(bindings)
@@ -162,8 +172,8 @@ def _get_dataframes(namespace: Dict[str, Any]) -> Dict[str, 
DataFrame]:
     return {k: v for k, v in namespace.items() if isinstance(v, DataFrame)}
 
 
-def _get_udfs(namespace: Dict[str, Any]) -> Dict[str, _DataFrameUDFWrapper]:
-    return {k: v for k, v in namespace.items() if isinstance(v, 
_DataFrameUDFWrapper)}
+def _get_udfs(namespace: Dict[str, Any]) -> Dict[str, _FunctionBinding]:
+    return {k: v for k, v in namespace.items() if isinstance(v, 
_FUNCTION_TYPES)}
 
 
 def _drop_views(t_env: TableEnvironment, names: List[str]) -> None:
@@ -322,10 +332,16 @@ def _register_views(
     return registered
 
 
+def _get_table_udf_wrapper(value: _FunctionBinding) -> 
UserDefinedFunctionWrapper:
+    if isinstance(value, _DataFrameUDTFWrapper):
+        return value._create_table_wrapper(preserve_field_names=True)
+    return value._table_udf_wrapper
+
+
 def _register_functions(
     t_env: TableEnvironment,
-    explicit: Dict[str, _DataFrameUDFWrapper],
-    auto: Dict[str, _DataFrameUDFWrapper],
+    explicit: Dict[str, _FunctionBinding],
+    auto: Dict[str, _FunctionBinding],
 ) -> List[str]:
     """
     Register explicit and auto-collected UDFs as temporary system functions 
and return
@@ -364,7 +380,7 @@ def _register_functions(
                     f"cannot bind '{name}': a temporary function with this 
name "
                     "already exists"
                 )
-            t_env.create_temporary_system_function(name, 
value._table_udf_wrapper)
+            t_env.create_temporary_system_function(name, 
_get_table_udf_wrapper(value))
             registered.append(name)
 
         # Auto-bound candidates are not expected to raise: problems are 
reported as
@@ -377,7 +393,7 @@ def _register_functions(
                 _warn_skipped(name, "a function with this name already exists")
                 continue
             try:
-                t_env.create_temporary_system_function(name, 
value._table_udf_wrapper)
+                t_env.create_temporary_system_function(name, 
_get_table_udf_wrapper(value))
             except Exception as e:
                 _warn_skipped(name, f"registration failed: {e}")
                 continue
diff --git a/flink-python/pyflink/dataframe/tests/test_sql.py 
b/flink-python/pyflink/dataframe/tests/test_sql.py
index bcc7b1190b7..be8d9033c94 100644
--- a/flink-python/pyflink/dataframe/tests/test_sql.py
+++ b/flink-python/pyflink/dataframe/tests/test_sql.py
@@ -18,6 +18,7 @@
 
 import unittest
 import warnings
+from typing import Iterator, TypedDict
 
 from py4j.protocol import Py4JJavaError
 
@@ -376,6 +377,99 @@ class SqlTests(PyFlinkDataFrameUTTestCase):
                 self.assertEqual(pf.sql(query).collect(), [Row(2)])
         self.assertNotIn("addone", self.t_env.list_user_defined_functions())
 
+    def test_udtf_explicit_and_auto_bindings(self):
+        source = pf.from_dict({"text": ["ab", ""]})
+
+        @pf.udtf(return_dtype=str)
+        def chars(text):
+            yield from text
+
+        query = "SELECT ch FROM source, LATERAL TABLE(chars(text)) AS T(ch)"
+        explicit = pf.sql(query, auto_bind=False, source=source, chars=chars)
+        automatic = pf.sql(query)
+        self.assertEqual(explicit.columns, ["ch"])
+        self.assertEqual(explicit.schema.get_field_data_types(), 
[DataTypes.STRING()])
+        self.assertCountEqual(explicit.union_all(automatic).collect(), 
[Row("a"), Row("b")] * 2)
+        self.assertNotIn("chars", self.t_env.list_user_defined_functions())
+        self.assertNotIn("source", self.t_env.list_temporary_views())
+
+    def test_udtf_declared_field_names_and_sql_aliases(self):
+        class Token(TypedDict):
+            word: str
+            size: int
+
+        def expand(text) -> Iterator[Token]:
+            yield {"word": text, "size": len(text)}
+
+        source = pf.from_dict({"text": ["ab"]})
+        for return_dtype in (None, "ROW<word STRING, size BIGINT>",
+                             pf.DataType.struct({"word": pf.DataType.string(),
+                                                 "size": 
pf.DataType.int64()})):
+            with self.subTest(return_dtype=return_dtype):
+                declaration = pf.udtf(expand, return_dtype=return_dtype)
+                named = pf.sql(
+                    "SELECT t.word, t.size FROM src, LATERAL 
TABLE(expand(text)) AS t",
+                    auto_bind=False, src=source, expand=declaration)
+                aliased = pf.sql(
+                    "SELECT t.w, t.n FROM src, LATERAL TABLE(expand(text)) AS 
t(w, n)",
+                    auto_bind=False, src=source, expand=declaration)
+                self.assertEqual(named.columns, ["word", "size"])
+                self.assertEqual(aliased.columns, ["w", "n"])
+                self.assertEqual(named.schema.get_field_data_types(),
+                                 [DataTypes.STRING(), DataTypes.BIGINT()])
+                self.assertEqual(named.union_all(aliased).collect(), 
[Row("ab", 2)] * 2)
+                self.assertNotIn("expand", 
self.t_env.list_user_defined_functions())
+
+    def test_udtf_sql_preserves_quoted_names_and_nested_types(self):
+        dtype = pf.DataType.struct({
+            "word text": pf.DataType.string().not_null(),
+            "meta": pf.DataType.struct({"size": pf.DataType.int64()}),
+        })
+        expand = pf.udtf(lambda text: {"word text": text, "meta": {"size": 
len(text)}},
+                         return_dtype=dtype)
+        result = pf.sql(
+            "SELECT t.`word text`, t.meta FROM src, LATERAL 
TABLE(expand(text)) AS t",
+            auto_bind=False, src=pf.from_dict({"text": ["ab"]}), expand=expand)
+        self.assertEqual(result.columns, ["word text", "meta"])
+        self.assertEqual(result.schema.get_field_data_types(), [
+            DataTypes.STRING().not_null(),
+            DataTypes.ROW([DataTypes.FIELD("size", DataTypes.BIGINT())])])
+        self.assertEqual(result.collect(), [Row("ab", Row(2))])
+
+    def test_udtf_sql_binding_after_flat_map_with_callable_class(self):
+        class Expand:
+            def __call__(self, value):
+                text = value["text"] if isinstance(value, dict) else value
+                yield from text
+
+        chars = pf.udtf(Expand, return_dtype=str)
+        source = pf.from_dict({"text": ["ab"]})
+        mapped = source.flat_map(chars)
+        query = "SELECT ch FROM src, LATERAL TABLE(chars(text)) AS T(ch)"
+        result = pf.sql(query, auto_bind=False, src=source, chars=chars)
+        self.assertCountEqual(mapped.union_all(result).collect(), [Row("a"), 
Row("b")] * 2)
+
+    def test_udtf_bindings_are_dropped_after_planning_failure(self):
+        source = pf.from_dict({"text": ["ab"]})
+        chars = pf.udtf(lambda text: list(text), return_dtype=str)
+        with self.assertRaises(Py4JJavaError):
+            pf.sql("SELECT * FROM src, LATERAL TABLE(chars(missing)) AS T(ch)",
+                   auto_bind=False, src=source, chars=chars)
+        self.assertNotIn("chars", self.t_env.list_user_defined_functions())
+        self.assertNotIn("src", self.t_env.list_temporary_views())
+
+    def test_udtf_binding_conflict_rolls_back_earlier_registrations(self):
+        self.t_env.create_temporary_system_function(
+            "taken", table_udf(lambda i: i + 100, 
result_type=DataTypes.BIGINT()))
+        self.addCleanup(self.t_env.drop_temporary_system_function, "taken")
+        chars = pf.udtf(lambda text: list(text), return_dtype=str)
+        with self.assertRaisesRegex(ValueError, "'taken'.*already exists"):
+            pf.sql("SELECT * FROM src", auto_bind=False,
+                   src=pf.from_dict({"text": ["ab"]}), first=chars, 
taken=chars)
+        self.assertNotIn("first", self.t_env.list_user_defined_functions())
+        self.assertNotIn("src", self.t_env.list_temporary_views())
+        self.assertIn("taken", self.t_env.list_user_defined_functions())
+
     def test_pandas_udfs_are_bindable(self):
         df = pf.from_dict({"a": [1, 2]})  # noqa: F841
         add_one = pf.udf(  # noqa: F841
diff --git a/flink-python/pyflink/dataframe/tests/test_udtf.py 
b/flink-python/pyflink/dataframe/tests/test_udtf.py
new file mode 100644
index 00000000000..80623aaaae1
--- /dev/null
+++ b/flink-python/pyflink/dataframe/tests/test_udtf.py
@@ -0,0 +1,708 @@
+################################################################################
+#  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 functools
+import os
+import threading
+import unittest
+from dataclasses import replace
+from typing import (
+    Annotated, Any, Dict, Generator, Iterable, Iterator, List, Mapping, 
MutableMapping,
+    Optional, Tuple, TypedDict, Union,
+)
+
+import cloudpickle
+import pyflink.dataframe as pf
+from pyflink.common import Row
+from pyflink.dataframe.udtf import _resolve_flat_map_udtf
+from pyflink.table import DataTypes
+from pyflink.table.udf import ScalarFunction, TableFunction
+from pyflink.testing.test_case_utils import (
+    PyFlinkBatchTableTestCase,
+    PyFlinkDataFrameUTTestCase,
+    PyFlinkStreamDataFrameTestCase,
+)
+
+
+class _Output(TypedDict):
+    value: int
+    label: str
+
+
+def _eval_udtf(declaration, *args):
+    adapter = declaration._create_table_wrapper()._func
+    adapter.open(None)
+    try:
+        return list(adapter.eval(*args))
+    finally:
+        adapter.close()
+
+
+class DataFrameUDTFDeclarationTests(unittest.TestCase):
+    def test_decorator_forms_and_type_inference(self):
+        def emit(value) -> Iterator[_Output]:
+            yield {"value": value, "label": "ok"}
+
+        expected = pf.DataType.struct({"value": pf.DataType.int64(), "label": 
pf.DataType.string()})
+        for declaration in (pf.udtf(emit), pf.udtf()(emit), 
pf.udtf(return_dtype=expected)(emit),
+                            pf.udtf(emit, return_dtype=_Output)):
+            self.assertEqual(declaration.return_dtype, expected)
+            self.assertEqual(declaration.__name__, "emit")
+
+    def test_iterable_annotations_describe_one_emitted_item(self):
+        hints = (Iterator[int], Iterable[int], Generator[int, None, None], 
List[int], list[int])
+        for hint in hints:
+            def emit(value):
+                return [value]
+            emit.__annotations__ = {"return": hint}
+            with self.subTest(hint=hint):
+                self.assertEqual(pf.udtf(emit).return_dtype, 
pf.DataType.int64())
+
+    def test_explicit_type_does_not_resolve_annotations(self):
+        def emit(value):
+            return [value]
+        emit.__annotations__ = {"value": "UnavailableInput", "return": 
"UnavailableOutput"}
+        self.assertEqual(pf.udtf(emit, return_dtype=int).return_dtype, 
pf.DataType.int64())
+        emit.__annotations__["return"] = Iterator[int]
+        self.assertEqual(pf.udtf(emit).return_dtype, pf.DataType.int64())
+
+    def test_callable_instance_and_partial(self):
+        class Repeat:
+            def __init__(self, count):
+                self.count = count
+
+            def __call__(self, value) -> Iterator[int]:
+                yield from [value] * self.count
+
+        def repeat(count, value) -> Iterator[int]:
+            yield from [value] * count
+
+        for func in (Repeat(2), functools.partial(repeat, 2)):
+            self.assertEqual(_eval_udtf(pf.udtf(func), 3), [Row(3), Row(3)])
+
+    def test_invalid_declarations(self):
+        class Scalar(ScalarFunction):
+            def eval(self, value):
+                return value
+
+        class NotCallable:
+            pass
+
+        class RequiresCallableArgument:
+            def __init__(self, count):
+                self.count = count
+
+            def __call__(self, value):
+                return [value] * self.count
+
+        class RequiresArgument(TableFunction):
+            def __init__(self, value):
+                self.value = value
+
+            def eval(self, value):
+                return [value]
+
+        for func in (123, Scalar(), Scalar, NotCallable,
+                     RequiresCallableArgument, RequiresArgument):
+            with self.subTest(func=func), self.assertRaises(TypeError):
+                pf.udtf(func, return_dtype=int)
+        for hint in (Iterator, Iterator[Tuple[int, ...]], Iterator[Tuple]):
+            def emit(row):
+                return []
+            emit.__annotations__ = {"return": hint}
+            with self.subTest(hint=hint), self.assertRaises(TypeError):
+                pf.udtf(emit)
+        with self.assertRaisesRegex(TypeError, "return_dtype"):
+            pf.udtf(lambda row: [])
+        with self.assertRaisesRegex(ValueError, "at least one"):
+            pf.udtf(lambda row: [], return_dtype=pf.DataType.struct({}))
+
+    def test_async_functions_are_rejected(self):
+        async def coroutine(row):
+            return [row]
+
+        async def generator(row):
+            yield row
+
+        class AsyncCallable:
+            async def __call__(self, row):
+                yield row
+
+        @functools.wraps(generator)
+        def hidden_generator(row):
+            return generator(row)
+
+        for func in (coroutine, generator, AsyncCallable, AsyncCallable(), 
hidden_generator):
+            with self.subTest(func=func), self.assertRaisesRegex(TypeError, 
"async"):
+                pf.udtf(func, return_dtype=int)
+
+    def test_names_follow_table_udtf_defaults(self):
+        def emit(row) -> Iterator[int]:
+            yield row[0]
+
+        class Expand(TableFunction):
+            def eval(self, row) -> Iterator[int]:
+                yield row[0]
+
+        class Repeat:
+            def __call__(self, row) -> Iterator[int]:
+                yield row[0]
+
+        cases = (
+            (emit, None, "emit"), (Expand, None, "Expand"), (Expand(), None, 
"Expand"),
+            (Repeat(), None, "Repeat"), (emit, "", "emit"), (emit, 
"custom_name", "custom_name"),
+        )
+        for func, name, expected in cases:
+            with self.subTest(func=func, name=name):
+                declaration = pf.udtf(func, name=name)
+                self.assertEqual(declaration.__name__, expected)
+                self.assertEqual(declaration._create_table_wrapper()._name, 
expected)
+
+    def test_determinism_validation(self):
+        with self.assertRaises(TypeError):
+            pf.udtf(lambda row: [], return_dtype=int, deterministic=1)
+
+        class Random(TableFunction):
+            def eval(self, value) -> Iterator[int]:
+                yield value
+
+            def is_deterministic(self):
+                return False
+
+        with self.assertRaisesRegex(ValueError, "Inconsistent deterministic"):
+            pf.udtf(Random())
+        adapter = pf.udtf(Random, deterministic=False, 
name="random")._create_table_wrapper()._func
+        adapter.open(None)
+        try:
+            self.assertFalse(adapter.is_deterministic())
+            self.assertEqual(list(adapter.eval(1)), [Row(1)])
+        finally:
+            adapter.close()
+        with self.assertRaisesRegex(ValueError, "Inconsistent deterministic"):
+            pf.udtf(Random)
+
+    def test_table_function_class_constructs_on_client(self):
+        events = []
+
+        class Expand(TableFunction):
+            def __init__(self):
+                events.append("init")
+
+            def open(self, context):
+                events.append(("open", context))
+
+            def eval(self, value) -> Iterator[int]:
+                yield value
+
+            def close(self):
+                events.append("close")
+
+        declaration = pf.udtf(Expand)
+        self.assertEqual(events, ["init"])
+        adapter = declaration._create_table_wrapper()._func
+        self.assertEqual(events, ["init"])
+        adapter.open("worker")
+        self.assertEqual(list(adapter.eval(7)), [Row(7)])
+        adapter.close()
+        self.assertEqual(events, ["init", ("open", "worker"), "close"])
+        with self.assertRaisesRegex(RuntimeError, "before open"):
+            adapter.eval(7)
+
+    def test_adapter_serializes_only_worker_metadata(self):
+        def emit(value) -> Iterator[int]:
+            yield value
+
+        declaration = pf.udtf(emit)
+        context = replace(declaration._declaration_context,
+                          localns={"client_lock": threading.Lock()})
+        declaration = replace(declaration, _declaration_context=context)
+        adapter = 
cloudpickle.loads(cloudpickle.dumps(declaration._create_table_wrapper()._func))
+        adapter.open(None)
+        try:
+            self.assertEqual(list(adapter.eval(5)), [Row(5)])
+        finally:
+            adapter.close()
+
+    def test_table_function_constructor_failure_is_reported_on_client(self):
+        class FailingConstructor(TableFunction):
+            def __init__(self):
+                raise RuntimeError("constructor failed")
+
+            def eval(self, value) -> Iterator[int]:
+                yield value
+
+        with self.assertRaisesRegex(RuntimeError, "constructor failed"):
+            pf.udtf(FailingConstructor)
+
+    def test_table_function_close_failure_resets_adapter(self):
+        class FailingClose(TableFunction):
+            def eval(self, value) -> Iterator[int]:
+                yield value
+
+            def close(self):
+                raise RuntimeError("close failed")
+
+        adapter = pf.udtf(FailingClose)._create_table_wrapper()._func
+        adapter.open(None)
+        self.assertEqual(list(adapter.eval(1)), [Row(1)])
+        with self.assertRaisesRegex(RuntimeError, "close failed"):
+            adapter.close()
+        with self.assertRaisesRegex(RuntimeError, "before open"):
+            adapter.eval(1)
+        adapter.close()
+
+    def test_output_cardinality_and_lazy_iteration(self):
+        events = []
+
+        def emit(value):
+            events.append(value)
+            yield value
+            events.append(value + 1)
+            yield value + 1
+
+        adapter = pf.udtf(emit, return_dtype=int)._create_table_wrapper()._func
+        adapter.open(None)
+        try:
+            output = adapter.eval(2)
+            self.assertEqual(events, [])
+            self.assertEqual(next(output), Row(2))
+            self.assertEqual(events, [2])
+            self.assertEqual(list(output), [Row(3)])
+        finally:
+            adapter.close()
+
+        cases = [(None, []), ([], []), (3, [Row(3)]), ((3,), [Row(3)]),
+                 (Row(3), [Row(3)]), ([None, 3], [Row(None), Row(3)])]
+        for result, expected in cases:
+            self.assertEqual(_eval_udtf(pf.udtf(lambda: result, 
return_dtype=int)), expected)
+
+    def test_struct_and_map_results(self):
+        dtype = pf.DataType.struct({
+            "value": pf.DataType.int64(),
+            "nested": pf.DataType.list(pf.DataType.struct({"label": 
pf.DataType.string()})),
+            "attributes": pf.DataType.map(pf.DataType.string(), 
pf.DataType.int64()),
+        })
+        result = {"attributes": {"x": 1}, "nested": [{"label": "ok"}], 
"value": 7}
+        self.assertEqual(_eval_udtf(pf.udtf(lambda: result, 
return_dtype=dtype)), [
+            Row(value=7, nested=[Row(label="ok")], attributes={"x": 1})])
+        map_type = pf.DataType.map(pf.DataType.string(), pf.DataType.int64())
+        for result in ({"x": 1}, Row({"x": 1}), ({"x": 1},)):
+            self.assertEqual(_eval_udtf(pf.udtf(lambda: result, 
return_dtype=map_type)),
+                             [Row({"x": 1})])
+
+    def test_runtime_errors_are_propagated(self):
+        dtype = pf.DataType.struct({"a": pf.DataType.int64(), "b": 
pf.DataType.int64()})
+        for result, return_dtype in (((1,), dtype), ((1, 2), 
pf.DataType.int64())):
+            declaration = pf.udtf(lambda: result, return_dtype=return_dtype)
+            with self.assertRaises(ValueError):
+                _eval_udtf(declaration)
+
+        def fail(row):
+            raise RuntimeError("user failure")
+        with self.assertRaisesRegex(RuntimeError, "user failure"):
+            _eval_udtf(pf.udtf(fail, return_dtype=int), Row(1))
+
+    def test_single_named_field_accepts_scalar_items(self):
+        cases = [
+            (pf.DataType.string(), ["hello", "world"], [Row(word="hello"), 
Row(word="world")]),
+            (pf.DataType.list(pf.DataType.int64()), [[1, 2], []], 
[Row(word=[1, 2]), Row(word=[])]),
+        ]
+        for field_type, result, expected in cases:
+            dtype = pf.DataType.struct({"word": field_type})
+            self.assertEqual(_eval_udtf(pf.udtf(lambda: result, 
return_dtype=dtype)), expected)
+
+    def test_flat_map_validates_row_input_before_building_expression(self):
+        def column(value: int) -> Iterator[int]:
+            yield value
+
+        def multiple(left, right) -> Iterator[int]:
+            yield left + right
+
+        def keyword(row, *, required) -> Iterator[int]:
+            yield required
+
+        for func in (column, multiple, keyword,
+                     pf.udtf(column), pf.udtf(multiple), pf.udtf(keyword)):
+            with self.subTest(func=func), self.assertRaisesRegex(ValueError, 
"row argument"):
+                _resolve_flat_map_udtf(func, None, ["x"])
+        with self.assertRaisesRegex(ValueError, "return_dtype"):
+            _resolve_flat_map_udtf(pf.udtf(column), int, ["x"])
+        with self.assertRaises(TypeError):
+            _resolve_flat_map_udtf(None, int, ["x"])
+
+    def test_incompatible_input_annotations(self):
+        for hint in (Row, Tuple[int], Optional[int], Union[int, str],
+                     Annotated[int, "column value"]):
+            def expand(row) -> Iterator[int]:
+                yield row
+            expand.__annotations__["row"] = hint
+            for func in (expand, pf.udtf(expand)):
+                with self.subTest(hint=hint, func=func):
+                    with self.assertRaisesRegex(ValueError, "row argument"):
+                        _resolve_flat_map_udtf(func, None, ["x"])
+
+    def test_flat_map_passes_dict_without_mutating_input(self):
+        received = []
+
+        @pf.udtf
+        def expand(row: Dict[str, Any]) -> Iterator[int]:
+            received.append(row.copy())
+            yield row.pop("x")
+
+        adapter = expand._create_table_wrapper(("z", "x"))._func
+        adapter.open(None)
+        self.addCleanup(adapter.close)
+        original = Row(original_z=7, original_x=3)
+        for value in ((7, 3), original):
+            self.assertEqual(list(adapter.eval(value)), [Row(3)])
+            self.assertEqual(received[-1], {"z": 7, "x": 3})
+        self.assertEqual(original.as_dict(), {"original_z": 7, "original_x": 
3})
+
+    def test_callable_class_initializes_per_worker_adapter(self):
+        events = []
+
+        class Counter:
+            def __init__(self):
+                events.append("init")
+                self.count = 0
+
+            def __call__(self, value: int) -> Iterator[int]:
+                self.count += 1
+                yield value + self.count
+
+        declaration = pf.udtf(Counter)
+        adapters = [declaration._create_table_wrapper()._func for _ in 
range(2)]
+        self.assertEqual(events, [])
+        for adapter in adapters:
+            adapter.open(None)
+            self.addCleanup(adapter.close)
+            self.assertEqual(list(adapter.eval(10)), [Row(11)])
+            self.assertEqual(list(adapter.eval(10)), [Row(12)])
+        self.assertEqual(events, ["init", "init"])
+        adapters[0].close()
+        with self.assertRaisesRegex(RuntimeError, "before open"):
+            adapters[0].eval(10)
+
+    def test_callable_class_constructor_failure_is_reported_on_worker(self):
+        class FailingConstructor:
+            def __init__(self):
+                raise RuntimeError("constructor failed")
+
+            def __call__(self, value) -> Iterator[int]:
+                yield value
+
+        adapter = pf.udtf(FailingConstructor)._create_table_wrapper()._func
+        with self.assertRaisesRegex(RuntimeError, "constructor failed"):
+            adapter.open(None)
+        adapter.close()
+
+
+class DataFrameUDTFPlanningTests(PyFlinkDataFrameUTTestCase):
+    def test_compatible_input_annotations(self):
+        class Input(TypedDict):
+            x: int
+
+        source = pf.from_dict({"x": [1]})
+        for hint in (Input, Optional[Input], Optional[Dict[str, Any]],
+                     Union[int, Dict[str, Any]], Mapping[str, Any], 
MutableMapping[str, Any],
+                     Annotated[Dict[str, Any], "input row"]):
+            def expand(row) -> Iterator[int]:
+                yield row["x"]
+            expand.__annotations__["row"] = hint
+            for func in (expand, pf.udtf(expand)):
+                with self.subTest(hint=hint, func=func):
+                    self.assertEqual(source.flat_map(func).columns, ["f0"])
+
+    def test_callable_class_signatures_and_annotations(self):
+        class Expand:
+            def __call__(self, row: Dict[str, Any], optional=0) -> 
Iterator[int]:
+                yield row["x"] + optional
+
+        class ClassMethod:
+            @classmethod
+            def __call__(cls, row: Dict[str, Any]) -> Iterator[int]:
+                yield row["x"]
+
+        class StaticMethod:
+            @staticmethod
+            def __call__(row: Dict[str, Any]) -> Iterator[int]:
+                yield row["x"]
+
+        class MultipleArguments:
+            def __call__(self, left, right) -> Iterator[int]:
+                yield left + right
+
+        class ColumnArgument:
+            def __call__(self, value: int) -> Iterator[int]:
+                yield value
+
+        source = pf.from_dict({"x": [1]})
+        for cls in (Expand, ClassMethod, StaticMethod):
+            for func in (cls, pf.udtf(cls)):
+                with self.subTest(func=func):
+                    self.assertEqual(source.flat_map(func).columns, ["f0"])
+        for cls in (MultipleArguments, ColumnArgument):
+            for func in (cls, pf.udtf(cls)):
+                with self.subTest(func=func), 
self.assertRaisesRegex(ValueError, "row argument"):
+                    source.flat_map(func)
+
+    def test_wrapped_static_method_still_validates_input_annotations(self):
+        def transparent(func):
+            @functools.wraps(func)
+            def wrapper(*args, **kwargs):
+                return func(*args, **kwargs)
+            return wrapper
+
+        class Expand:
+            @staticmethod
+            @transparent
+            def __call__(value: int) -> Iterator[int]:
+                yield value
+
+        source = pf.from_dict({"x": [1]})
+        for func in (Expand, pf.udtf(Expand), Expand(), pf.udtf(Expand())):
+            with self.subTest(func=func), self.assertRaisesRegex(ValueError, 
"dict row argument"):
+                source.flat_map(func)
+
+    def test_unnamed_multiple_fields_require_schema(self):
+        def emit(row) -> Iterator[Tuple[int, str]]:
+            yield row["x"], "ok"
+
+        source = pf.from_dict({"x": [1]})
+        with self.assertRaisesRegex(ValueError, "named output fields"):
+            source.flat_map(emit)
+        named = source.flat_map(emit, return_dtype="ROW<value BIGINT, label 
STRING>")
+        self.assertEqual(named.columns, ["value", "label"])
+
+    def test_call_metadata_aliases_and_shared_declaration(self):
+        @pf.udtf
+        def emit(value) -> Iterator[_Output]:
+            yield {"value": value, "label": "ok"}
+
+        source = pf.from_dict({"x": [1]})
+        call = emit(pf.col("x"))
+        aliased = call.alias("v", "l")
+        self.assertIsNone(call.output_aliases)
+        self.assertEqual(aliased.output_aliases, ("v", "l"))
+        for names in (("one",), ("same", "same"), ("", "other")):
+            with self.subTest(names=names), self.assertRaises(ValueError):
+                call.alias(*names)
+        table = source.to_table().join_lateral(aliased.expression)
+        self.assertEqual(pf.from_table(table).columns, ["x", "v", "l"])
+
+    def test_constructing_plan_does_not_reconstruct_user_class(self):
+        events = []
+
+        class Expand(TableFunction):
+            def __init__(self):
+                events.append("init")
+
+            def eval(self, row: Dict[str, Any]) -> Iterator[int]:
+                yield next(iter(row.values()))
+
+        declaration = pf.udtf(Expand)
+        self.assertEqual(events, ["init"])
+        for name in ("x", "y"):
+            result = pf.from_dict({name: [1]}).flat_map(declaration)
+            self.assertEqual(result.columns, ["f0"])
+        self.assertEqual(events, ["init"])
+
+
+class _DataFrameFlatMapTests:
+    def setUp(self):
+        super().setUp()
+        previous = pf.get_table_environment()
+        self.addCleanup(pf.set_table_environment, previous)
+        pf.set_table_environment(self.t_env)
+
+    def test_typed_dict_input_and_named_output_pipeline(self):
+        class Input(TypedDict):
+            count: int
+            label: str
+
+        def expand(row: Input) -> Iterator[_Output]:
+            for value in range(row["count"]):
+                yield {"label": row["label"], "value": value}
+
+        source = pf.from_records([(0, "empty"), (1, "a"), (2, "b")], 
schema=["count", "label"])
+        result = source.flat_map(expand)
+        self.assertEqual(result.columns, ["value", "label"])
+        self.assertEqual(result.schema.get_field_data_types(),
+                         [DataTypes.BIGINT(), DataTypes.STRING()])
+        rows = result.filter(pf.col("value") >= 0).select("label", 
"value").collect()
+        self.assertCountEqual(rows, [Row("a", 0), Row("b", 0), Row("b", 1)])
+
+    def test_plain_and_decorated_functions_receive_dict(self):
+        dtype = pf.DataType.struct({
+            "is_dict": pf.DataType.bool(),
+            "first": pf.DataType.int64(),
+            "by_name": pf.DataType.int64(),
+        })
+
+        def expand(row: Dict[str, Any]):
+            yield isinstance(row, dict), next(iter(row.values())), row["x"]
+
+        source = pf.from_records([(7, 3)], schema=["z", "x"])
+        plain = source.flat_map(expand, return_dtype=dtype)
+        decorated = source.flat_map(pf.udtf(expand, return_dtype=dtype))
+        self.assertEqual(plain.columns, ["is_dict", "first", "by_name"])
+        self.assertEqual(decorated.columns, plain.columns)
+        self.assertEqual(plain.union_all(decorated).collect(), [Row(True, 7, 
3)] * 2)
+
+    def test_wrapper_row_input_reuse_and_column_calls(self):
+        @pf.udtf
+        def expand(value) -> Iterator[int]:
+            value = next(iter(value.values())) if isinstance(value, dict) else 
value
+            yield value
+            yield value + 1
+
+        a = pf.from_dict({"x": [1]})
+        b = pf.from_dict({"y": [3]})
+        before = expand(pf.col("x")).alias("out")
+        first, second = a.flat_map(expand), b.flat_map(expand)
+        self.assertEqual(first.columns, ["f0"])
+        after = expand(pf.col("x")).alias("out")
+        combined = first.union_all(second)
+        for call in (before, after):
+            lateral = 
pf.from_table(a.to_table().join_lateral(call.expression)).select("out")
+            combined = combined.union_all(lateral)
+        self.assertCountEqual(combined.collect(), [Row(1), Row(2)] * 3 + 
[Row(3), Row(4)])
+
+    def test_table_function_and_callable_classes_and_instances(self):
+        client_pid = os.getpid()
+
+        class Expand(TableFunction):
+            def __init__(self):
+                self.constructor_pid = os.getpid()
+
+            def open(self, context):
+                self.offset = 10
+
+            def eval(self, row: Dict[str, Any]) -> Iterator[int]:
+                if self.constructor_pid != client_pid:
+                    raise AssertionError("TableFunction must be constructed on 
the client")
+                yield row["x"] + self.offset
+
+        class Repeat:
+            def __call__(self, row: Dict[str, Any]) -> Iterator[int]:
+                yield row["x"]
+                yield row["x"]
+
+        source = pf.from_dict({"x": [1]})
+        first = source.flat_map(pf.udtf(Expand))
+        second = source.flat_map(pf.udtf(Expand()))
+        combined = first.union_all(second)
+        for func in (Repeat, Repeat(), pf.udtf(Repeat), pf.udtf(Repeat())):
+            combined = combined.union_all(source.flat_map(func))
+        self.assertCountEqual(combined.collect(), [Row(11)] * 2 + [Row(1)] * 8)
+
+    def test_wrapped_callable_class_methods(self):
+        def transparent(func):
+            @functools.wraps(func)
+            def wrapper(*args, **kwargs):
+                return func(*args, **kwargs)
+            return wrapper
+
+        class InstanceMethod:
+            @transparent
+            def __call__(self, row: Dict[str, Any]) -> Iterator[int]:
+                yield row["x"]
+
+        class StaticMethod:
+            @staticmethod
+            @transparent
+            def __call__(row: Dict[str, Any]) -> Iterator[int]:
+                yield row["x"]
+
+        class ClassMethod:
+            @classmethod
+            @transparent
+            def __call__(cls, row: Dict[str, Any]) -> Iterator[int]:
+                yield row["x"]
+
+        class InheritedStaticMethod(StaticMethod):
+            pass
+
+        def expand(row: Dict[str, Any]) -> Iterator[int]:
+            yield row["x"]
+
+        class WrappedFunction:
+            @functools.wraps(expand)
+            def __call__(self, *args, **kwargs):
+                return expand(*args, **kwargs)
+
+        source = pf.from_dict({"x": [1]})
+        combined = None
+        for cls in (InstanceMethod, StaticMethod, ClassMethod,
+                    InheritedStaticMethod, WrappedFunction):
+            self.assertEqual(list(cls()({"x": 1})), [1])
+            for func in (cls(), pf.udtf(cls())):
+                self.assertEqual(source.flat_map(func).columns, ["f0"])
+            for func in (cls, pf.udtf(cls)):
+                result = source.flat_map(func)
+                combined = result if combined is None else 
combined.union_all(result)
+        self.assertCountEqual(combined.collect(), [Row(1)] * 10)
+
+    def test_nested_values_and_nulls(self):
+        dtype = pf.DataType.struct({
+            "payload": pf.DataType.struct({"label": pf.DataType.string()}),
+            "values": pf.DataType.list(pf.DataType.int64()),
+            "attributes": pf.DataType.map(pf.DataType.string(), 
pf.DataType.int64()),
+            "missing": pf.DataType.string(),
+        })
+
+        def expand(row: Optional[Dict[str, Any]]):
+            if row["x"] == 0:
+                return None
+            return {"payload": {"label": "ok"}, "values": [1, None], 
"attributes": {"a": 2}}
+
+        result = pf.from_dict({"x": [0, 1]}).flat_map(expand, 
return_dtype=dtype)
+        # Table.collect cannot decode NULL array elements, so inspect them in 
the JVM.
+        projected = result.select(
+            "payload", pf.col("values").cardinality, pf.col("values").at(1),
+            pf.col("values").at(2).is_null, "attributes", "missing")
+        self.assertEqual(projected.collect(), [Row(Row("ok"), 2, 1, True, 
{"a": 2}, None)])
+
+    def test_map_output_is_one_column(self):
+        def expand(row) -> Iterator[Dict[str, int]]:
+            yield {"value": row["x"]}
+            yield {"value": row["x"] + 1}
+
+        result = pf.from_dict({"x": [1]}).flat_map(expand)
+        self.assertEqual(result.columns, ["f0"])
+        self.assertEqual(result.schema.get_field_data_types(),
+                         [DataTypes.MAP(DataTypes.STRING(), 
DataTypes.BIGINT())])
+        self.assertCountEqual(result.collect(), [Row({"value": 1}), 
Row({"value": 2})])
+
+
+class DataFrameFlatMapStreamTests(_DataFrameFlatMapTests, 
PyFlinkStreamDataFrameTestCase):
+    pass
+
+
+class DataFrameFlatMapBatchTests(_DataFrameFlatMapTests, 
PyFlinkBatchTableTestCase):
+    pass
+
+
+class DataFrameFlatMapThreadTests(_DataFrameFlatMapTests, 
PyFlinkStreamDataFrameTestCase):
+    def setUp(self):
+        super().setUp()
+        self.t_env.get_config().set("python.execution-mode", "thread")
+
+
+if __name__ == "__main__":
+    unittest.main()
diff --git a/flink-python/pyflink/dataframe/udtf.py 
b/flink-python/pyflink/dataframe/udtf.py
new file mode 100644
index 00000000000..f1b92a1839e
--- /dev/null
+++ b/flink-python/pyflink/dataframe/udtf.py
@@ -0,0 +1,518 @@
+################################################################################
+#  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.
+################################################################################
+
+"""User-defined table functions for the DataFrame API."""
+
+import collections.abc
+import functools
+import inspect
+import types
+from dataclasses import dataclass
+from typing import (
+    Annotated, Any, Callable, Iterator, List, Optional, Tuple, Type, Union, 
cast, get_args,
+    get_origin, overload,
+)
+
+from pyflink.common import Row
+from pyflink.dataframe.datatype import DataType
+from pyflink.dataframe.udf import (
+    _DataTypeLike,
+    _UDFDeclarationContext,
+    _UNRESOLVED_TYPE_HINT,
+    _convert_to_dtype,
+    _create_declaration_context,
+    _create_result_normalizer,
+    _data_type_from_type_hint,
+    _get_callable_inspection_target,
+    _get_callable_return_type_hint,
+    _is_typed_dict,
+    _preserves_method_binding,
+    _resolve_callable_annotation,
+    _resolve_udf,
+    _validate_determinism_agreement,
+    _validate_zero_argument_class,
+)
+from pyflink.table.expression import Expression
+from pyflink.table.expressions import col, with_columns
+from pyflink.table.types import RowType, _to_java_data_type
+from pyflink.table.udf import (
+    TableFunction,
+    UserDefinedFunction,
+    UserDefinedTableFunctionWrapper,
+    udtf as table_udtf,
+)
+from pyflink.util.api_stability_decorators import PublicEvolving
+
+__all__ = ["udtf"]
+
+_UDTFInput = Union[Callable[..., Any], TableFunction, Type]
+
+
+@dataclass(frozen=True)
+class _DataFrameUDTFCall:
+    expression: Expression
+    return_dtype: DataType
+    has_named_fields: bool
+    output_aliases: Optional[Tuple[str, ...]] = None
+
+    def alias(self, name: str, *extra_names: str) -> "_DataFrameUDTFCall":
+        names = (name,) + extra_names
+        table_type = self.return_dtype._to_table_data_type()
+        arity = len(table_type.fields) if isinstance(table_type, RowType) else 
1
+        if any(not isinstance(value, str) for value in names):
+            raise TypeError("UDTF output aliases must be strings.")
+        if len(names) != arity or len(set(names)) != len(names) or not 
all(names):
+            raise ValueError(
+                "UDTF output aliases must be non-empty, unique, and match 
output arity.")
+        return _DataFrameUDTFCall(
+            self.expression.alias(*names), self.return_dtype, 
self.has_named_fields, names
+        )
+
+
+@dataclass(frozen=True)
+class _DataFrameUDTFWrapper:
+    _func: _UDTFInput
+    _declaration_context: _UDFDeclarationContext
+    return_dtype: DataType
+    _has_named_fields: bool
+    _deterministic: bool
+    _name: Optional[str]
+
+    @property
+    def __name__(self) -> str:
+        return self._create_table_wrapper()._name
+
+    def __call__(self, *args: Any) -> _DataFrameUDTFCall:
+        return _DataFrameUDTFCall(
+            self._create_table_wrapper()(*args), self.return_dtype, 
self._has_named_fields
+        )
+
+    def _create_table_wrapper(
+        self, input_columns: Optional[Tuple[str, ...]] = None, *,
+        preserve_field_names: bool = False,
+    ) -> UserDefinedTableFunctionWrapper:
+        """Keep Table's mutable row-input flag local to each use of this 
declaration."""
+        result_types = self.return_dtype._to_table_data_type()
+        if preserve_field_names and isinstance(result_types, RowType):
+            # Table's RowType path drops field names; its SQL type declaration 
preserves them.
+            result_types = 
_to_java_data_type(result_types).getLogicalType().asSerializableString()
+        return cast(UserDefinedTableFunctionWrapper, table_udtf(
+            _DataFrameTableFunctionAdapter(
+                self._func,
+                self.return_dtype,
+                self._deterministic,
+                input_columns,
+            ),
+            result_types=result_types,
+            deterministic=self._deterministic,
+            name=self._name,
+        ))
+
+
+@overload
+def udtf(
+    func: _UDTFInput,
+    *,
+    return_dtype: Optional[_DataTypeLike] = ...,
+    deterministic: bool = ...,
+    name: Optional[str] = ...,
+) -> _DataFrameUDTFWrapper:
+    ...
+
+
+@overload
+def udtf(
+    func: None = ...,
+    *,
+    return_dtype: Optional[_DataTypeLike] = ...,
+    deterministic: bool = ...,
+    name: Optional[str] = ...,
+) -> Callable[[_UDTFInput], _DataFrameUDTFWrapper]:
+    ...
+
+
+@PublicEvolving()
+def udtf(
+    func: Optional[_UDTFInput] = None,
+    *,
+    return_dtype: Optional[_DataTypeLike] = None,
+    deterministic: bool = True,
+    name: Optional[str] = None,
+) -> Union[_DataFrameUDTFWrapper, Callable[[_UDTFInput], 
_DataFrameUDTFWrapper]]:
+    """
+    Declare a synchronous function that emits zero or more rows per invocation.
+
+    Supports functions, callable classes and instances, and ``TableFunction``
+    classes and instances. Classes must have a zero-argument constructor. Plain
+    callable classes are instantiated on workers, as with 
:func:`pyflink.dataframe.udf`.
+    ``TableFunction`` classes are instantiated on the client when declared;
+    their ``open`` and ``close`` methods run on workers. Explicitly provided
+    instances are serialized with the job.
+
+    The output type may be explicit or inferred from ``Iterator[T]``, 
``Iterable[T]``,
+    ``Generator[T, ...]``, or ``list[T]``. A ``TypedDict`` supplies output 
field names.
+    Return ``None`` for no rows, a list/generator for multiple rows, or a 
scalar,
+    tuple, ``Row``, or dict for one row. Nested output values follow scalar 
UDF rules.
+
+    When used with :meth:`DataFrame.flat_map`, the function receives a 
dictionary
+    keyed by input column name. Expression and SQL calls pass the specified 
arguments.
+
+    Example::
+
+        >>> from typing import Any, Dict, Iterator, TypedDict
+        >>> import pyflink.dataframe as pf
+
+        >>> class Output(TypedDict):
+        ...     value: int
+
+        >>> @pf.udtf
+        ... def expand(record: Dict[str, Any]) -> Iterator[Output]:
+        ...     yield {"value": record["x"]}
+        ...     yield {"value": record["x"] + 1}
+
+        >>> df = pf.from_dict({"x": [1, 2]})
+        >>> result = df.flat_map(expand)
+
+    Pass a callable class directly for a one-off ``flat_map`` operation. The 
class
+    must support zero-argument construction and is instantiated on workers::
+
+        >>> class Repeat:
+        ...     def __init__(self, times=2):
+        ...         self.times = times
+        ...
+        ...     def __call__(self, record: Dict[str, Any]):
+        ...         for _ in range(self.times):
+        ...             yield record["x"]
+
+        >>> repeated = df.flat_map(Repeat, return_dtype=int)
+        >>> repeated.columns
+        ['f0']
+
+    Create a UDTF declaration when the callable will be reused or its metadata 
should
+    be declared once. Passing the class keeps worker-side construction::
+
+        >>> repeat = pf.udtf(Repeat, return_dtype=int)
+        >>> first = df.flat_map(repeat)
+        >>> second = df.flat_map(repeat)
+
+    When constructor arguments are needed, create an instance on the client. 
Pass it
+    directly for one-off use, or wrap it with :func:`udtf` to reuse the 
declaration
+    without repeating its metadata::
+
+        >>> repeat_three_times = Repeat(3)
+        >>> one_off = df.flat_map(repeat_three_times, return_dtype=int)
+        >>> reusable_repeat_three_times = pf.udtf(repeat_three_times, 
return_dtype=int)
+        >>> reused = df.flat_map(reusable_repeat_three_times)
+
+    A ``TableFunction`` class is instantiated on the client when declared. Use 
an
+    instance for constructor configuration; in both cases, ``open`` runs on 
workers::
+
+        >>> from pyflink.table.udf import TableFunction
+
+        >>> class Expand(TableFunction):
+        ...     def __init__(self, offset=1):
+        ...         self.offset = offset
+        ...
+        ...     def open(self, context):
+        ...         self.worker_offset = self.offset
+        ...
+        ...     def eval(self, record: Dict[str, Any]) -> Iterator[int]:
+        ...         yield record["x"] + self.worker_offset
+
+        >>> default_expand = pf.udtf(Expand)
+        >>> configured_expand = pf.udtf(Expand(offset=10))
+        >>> default_result = df.flat_map(default_expand)
+        >>> configured_result = df.flat_map(configured_expand)
+
+    :param func: Function, callable class/instance, or ``TableFunction`` 
class/instance.
+    :param return_dtype: Emitted row type, as a DataFrame DataType, Python 
type,
+                         or SQL type string. Inferred from annotations when 
omitted.
+    :param deterministic: Whether equal inputs produce equal results; must 
agree
+                          with ``TableFunction.is_deterministic``.
+    :param name: Optional function name. None or an empty string uses the 
callable's name.
+    :return: A reusable UDTF declaration, or a decorator when ``func`` is 
omitted.
+
+    .. versionadded:: 2.4.0
+    """
+    def decorator(f: _UDTFInput) -> _DataFrameUDTFWrapper:
+        actual_func, context = _resolve_udtf(f)
+        dtype, has_named_fields = _infer_udtf_return_dtype(context, 
return_dtype)
+        if not isinstance(deterministic, bool):
+            raise TypeError("deterministic must be a bool.")
+        if isinstance(actual_func, TableFunction):
+            _validate_determinism_agreement(deterministic, 
actual_func.is_deterministic())
+        return _DataFrameUDTFWrapper(
+            actual_func, context, dtype, has_named_fields, deterministic, name)
+
+    return decorator if func is None else decorator(func)
+
+
+def _resolve_udtf(
+    func: _UDTFInput,
+) -> Tuple[_UDTFInput, _UDFDeclarationContext]:
+    if inspect.isclass(func) and issubclass(func, TableFunction):
+        _validate_zero_argument_class(func)
+        func = func()
+        if not isinstance(func, TableFunction):
+            raise TypeError("A TableFunction class must construct a 
TableFunction instance.")
+    if isinstance(func, TableFunction):
+        if not callable(func.eval):
+            raise TypeError("TableFunction.eval must be callable.")
+        context = _create_declaration_context(func.eval, 
partial_source=func.eval)
+    else:
+        if isinstance(func, UserDefinedFunction) or (
+            inspect.isclass(func) and issubclass(func, UserDefinedFunction)
+        ):
+            raise TypeError("func must be a table UDF or a Python callable.")
+        context = _resolve_udf(func).declaration_context
+    target = context.annotation_target
+    try:
+        unwrapped_target = inspect.unwrap(target)
+    except ValueError as exc:
+        raise TypeError("Cannot inspect a UDTF with a wrapper cycle.") from exc
+    if any(
+        inspect.iscoroutinefunction(candidate) or 
inspect.isasyncgenfunction(candidate)
+        for candidate in (target, unwrapped_target)
+    ):
+        raise TypeError("DataFrame UDTFs must be synchronous; async functions 
are not supported.")
+    return func, context
+
+
+def _infer_udtf_return_dtype(
+    context: _UDFDeclarationContext, return_dtype: Optional[_DataTypeLike]
+) -> Tuple[DataType, bool]:
+    if return_dtype is not None:
+        dtype = _convert_to_dtype(return_dtype)
+        has_named_fields = isinstance(dtype._to_table_data_type(), RowType)
+    else:
+        hint = _get_callable_return_type_hint(context)
+        origin, arguments = get_origin(hint), get_args(hint)
+        if origin in (
+            collections.abc.Iterator, collections.abc.Iterable, 
collections.abc.Generator, list
+        ) and arguments:
+            hint = arguments[0]
+        elif hint is _UNRESOLVED_TYPE_HINT or origin in (
+            collections.abc.Iterator, collections.abc.Iterable, 
collections.abc.Generator, list
+        ):
+            raise TypeError("Cannot infer UDTF return type; specify 
return_dtype explicitly.")
+        if get_origin(hint) is tuple:
+            fields = get_args(hint)
+            if not fields or Ellipsis in fields:
+                raise TypeError("UDTF tuple outputs must have a fixed number 
of fields.")
+            dtype = DataType.struct([
+                (f"f{index}", _data_type_from_type_hint(field))
+                for index, field in enumerate(fields)
+            ])
+            has_named_fields = False
+        else:
+            dtype = _data_type_from_type_hint(hint)
+            has_named_fields = isinstance(dtype._to_table_data_type(), RowType)
+    table_type = dtype._to_table_data_type()
+    if isinstance(table_type, RowType) and not table_type.fields:
+        raise ValueError("A UDTF must declare at least one output field.")
+    return dtype, has_named_fields
+
+
+def _validate_flat_map_input(declaration: _DataFrameUDTFWrapper) -> None:
+    source = declaration._func
+    target: Callable[..., Any]
+    if inspect.isclass(source):
+        target = declaration._declaration_context.annotation_target
+    elif isinstance(source, TableFunction):
+        target = source.eval
+    elif isinstance(source, functools.partial):
+        target = source
+    else:
+        target = _get_callable_inspection_target(source)
+    if inspect.ismethod(target) and hasattr(target, "__wrapped__") and not (
+        _preserves_method_binding(target, 
declaration._declaration_context.defining_class)
+    ):
+        target = target.__func__
+    try:
+        signature = inspect.signature(target)
+    except (TypeError, ValueError):
+        return
+    if inspect.isclass(source):
+        descriptor = inspect.getattr_static(source, "__call__")
+        binds_receiver = not isinstance(descriptor, staticmethod)
+        if hasattr(target, "__wrapped__"):
+            binds_receiver = binds_receiver and _preserves_method_binding(
+                target, declaration._declaration_context.defining_class)
+        parameters = list(signature.parameters.values())
+        if binds_receiver and parameters and parameters[0].kind in (
+            inspect.Parameter.POSITIONAL_ONLY, 
inspect.Parameter.POSITIONAL_OR_KEYWORD
+        ):
+            signature = signature.replace(parameters=parameters[1:])
+    parameters = list(signature.parameters.values())
+    try:
+        signature.bind(object())
+    except TypeError as exc:
+        raise ValueError("flat_map requires a function accepting one row 
argument.") from exc
+    positional = [p for p in parameters if p.kind in (
+        inspect.Parameter.POSITIONAL_ONLY, 
inspect.Parameter.POSITIONAL_OR_KEYWORD
+    )]
+    if not positional:
+        return
+    hint = _resolve_callable_annotation(declaration._declaration_context, 
positional[0].name)
+    if not _is_flat_map_row_hint(hint):
+        raise ValueError(f"flat_map receives one dict row argument, got 
annotation {hint}.")
+
+
+def _is_flat_map_row_hint(hint: Any) -> bool:
+    if hint in (_UNRESOLVED_TYPE_HINT, Any, object):
+        return True
+    origin = get_origin(hint)
+    if origin is Annotated:
+        return _is_flat_map_row_hint(get_args(hint)[0])
+    if origin in (Union, getattr(types, "UnionType", Union)):
+        return any(_is_flat_map_row_hint(member) for member in get_args(hint))
+    if _is_typed_dict(hint):
+        return True
+    target = origin or hint
+    try:
+        return issubclass(dict, target)
+    except TypeError:
+        # Leave annotations that cannot be checked at runtime to the user's 
type checker.
+        return True
+
+
+def _resolve_flat_map_udtf(
+    func: Union[Callable[..., Any], Type, _DataFrameUDTFWrapper],
+    return_dtype: Optional[_DataTypeLike],
+    input_columns: List[str],
+) -> Tuple[Expression, List[str]]:
+    if isinstance(func, _DataFrameUDTFWrapper):
+        if return_dtype is not None:
+            raise ValueError("return_dtype must not be specified for a 
DataFrame UDTF declaration.")
+        declaration = func
+    else:
+        if func is None:
+            raise TypeError("flat_map requires a callable or a pf.udtf 
declaration.")
+        if isinstance(func, UserDefinedFunction) or (
+            inspect.isclass(func) and issubclass(func, UserDefinedFunction)
+        ):
+            raise TypeError("flat_map accepts Python callables or a pf.udtf 
declaration.")
+        declaration = udtf(func, return_dtype=return_dtype)
+    _validate_flat_map_input(declaration)
+    table_type = declaration.return_dtype._to_table_data_type()
+    if declaration._has_named_fields:
+        output_columns = cast(RowType, table_type).field_names()
+    elif isinstance(table_type, RowType) and len(table_type.fields) > 1:
+        raise ValueError("flat_map requires named output fields; use TypedDict 
or a named struct.")
+    else:
+        output_columns = ["f0"]
+    wrapper = declaration._create_table_wrapper(tuple(input_columns))
+    wrapper._set_takes_row_as_input()
+    return wrapper(with_columns(col("*"))), output_columns
+
+
+def _iter_user_results(result: Any) -> Iterator[Any]:
+    if result is None:
+        return
+    if isinstance(result, (Row, tuple, collections.abc.Mapping, str, bytes, 
bytearray)):
+        yield result
+    elif isinstance(result, collections.abc.Iterable):
+        yield from result
+    else:
+        yield result
+
+
+class _DataFrameTableFunctionAdapter(TableFunction):
+    def __init__(
+        self,
+        func: _UDTFInput,
+        return_dtype: DataType,
+        deterministic: bool,
+        input_columns: Optional[Tuple[str, ...]],
+    ) -> None:
+        self._func = func
+        self._return_dtype = return_dtype
+        self._deterministic = deterministic
+        self._input_columns = input_columns
+        self._bound_invocation: Optional[Callable[..., Any]] = None
+        self._lifecycle_opened = False
+        self.__name__ = getattr(func, "__name__", type(func).__name__)
+
+    def open(self, function_context: Any) -> None:
+        if isinstance(self._func, TableFunction):
+            self._func.open(function_context)
+            self._lifecycle_opened = True
+            invoke_func = self._func.eval
+        else:
+            invoke_func = self._func() if inspect.isclass(self._func) else 
self._func
+            if not callable(invoke_func):
+                raise TypeError("UDTF class must construct a callable 
instance.")
+        try:
+            self._bound_invocation = self._bind_func(invoke_func)
+        except Exception:
+            try:
+                self.close()
+            except Exception:
+                pass
+            raise
+
+    def close(self) -> None:
+        try:
+            if self._lifecycle_opened:
+                cast(TableFunction, self._func).close()
+        finally:
+            self._bound_invocation = None
+            self._lifecycle_opened = False
+
+    def is_deterministic(self) -> bool:
+        if isinstance(self._func, TableFunction):
+            return self._func.is_deterministic()
+        return self._deterministic
+
+    def _bind_func(self, invoke_func: Callable[..., Any]) -> Callable[..., 
Any]:
+        table_type = self._return_dtype._to_table_data_type()
+        normalizer = _create_result_normalizer(table_type)
+        is_struct = isinstance(table_type, RowType)
+        field_count = len(table_type.fields) if isinstance(table_type, 
RowType) else 1
+        input_columns = list(self._input_columns) if self._input_columns is 
not None else None
+
+        def invoke(*args: Any) -> Iterator[Row]:
+            if input_columns is not None:
+                input_row = args[0]
+                row = {name: input_row[i] for i, name in 
enumerate(input_columns)}
+                result = invoke_func(row)
+            else:
+                result = invoke_func(*args)
+            for item in _iter_user_results(result):
+                if is_struct and field_count == 1 and item is not None:
+                    if not isinstance(item, (Row, tuple, 
collections.abc.Mapping)):
+                        item = Row(item)
+                if not is_struct and isinstance(item, (Row, tuple)):
+                    if len(item) != 1:
+                        raise ValueError("UDTF scalar output requires exactly 
one field.")
+                    item = item[0]
+                normalized = normalizer(item) if normalizer is not None else 
item
+                if is_struct:
+                    if normalized is None:
+                        yield Row(*([None] * field_count))
+                    else:
+                        yield normalized
+                else:
+                    yield Row(normalized)
+
+        return invoke
+
+    def eval(self, *args: Any) -> Iterator[Row]:
+        if self._bound_invocation is None:
+            raise RuntimeError("DataFrame UDTF was invoked before open().")
+        return self._bound_invocation(*args)

Reply via email to