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 81389aca713 [FLINK-40431][python] Add UDF support to DataFrame API 
(#29029)
81389aca713 is described below

commit 81389aca7136fd2e69d9c308dbe0db005ddd46b2
Author: Liu Liu <[email protected]>
AuthorDate: Wed Sep 2 23:18:03 2026 +0800

    [FLINK-40431][python] Add UDF support to DataFrame API (#29029)
---
 .../docs/reference/pyflink.dataframe/index.rst     |    1 +
 .../pyflink.dataframe/{index.rst => udf.rst}       |   32 +-
 flink-python/pyflink/dataframe/__init__.py         |    2 +
 flink-python/pyflink/dataframe/dataframe.py        |   11 +-
 flink-python/pyflink/dataframe/tests/test_udf.py   | 1581 ++++++++++++++++++++
 flink-python/pyflink/dataframe/udf.py              | 1297 ++++++++++++++++
 6 files changed, 2911 insertions(+), 13 deletions(-)

diff --git a/flink-python/docs/reference/pyflink.dataframe/index.rst 
b/flink-python/docs/reference/pyflink.dataframe/index.rst
index 5f0645c2baa..197cfccdaca 100644
--- a/flink-python/docs/reference/pyflink.dataframe/index.rst
+++ b/flink-python/docs/reference/pyflink.dataframe/index.rst
@@ -26,6 +26,7 @@ This page gives an overview of all public PyFlink DataFrame 
APIs.
     :maxdepth: 1
 
     dataframe
+    udf
     creation
     io
     sql
diff --git a/flink-python/docs/reference/pyflink.dataframe/index.rst 
b/flink-python/docs/reference/pyflink.dataframe/udf.rst
similarity index 58%
copy from flink-python/docs/reference/pyflink.dataframe/index.rst
copy to flink-python/docs/reference/pyflink.dataframe/udf.rst
index 5f0645c2baa..73d1ff2abe7 100644
--- a/flink-python/docs/reference/pyflink.dataframe/index.rst
+++ b/flink-python/docs/reference/pyflink.dataframe/udf.rst
@@ -16,18 +16,26 @@
     limitations under the License.
    
################################################################################
 
-==================
-PyFlink DataFrame
-==================
+=============================
+User-Defined Scalar Functions
+=============================
 
-This page gives an overview of all public PyFlink DataFrame APIs.
+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
+:meth:`~pyflink.dataframe.DataFrame.with_column`,
+:meth:`~pyflink.dataframe.DataFrame.with_columns`, and
+:meth:`~pyflink.dataframe.DataFrame.select`.
 
-.. toctree::
-    :maxdepth: 1
+DataFrame scalar UDFs support synchronous, asynchronous, and pandas-vectorized
+callables. See :func:`pyflink.dataframe.udf` for declaration forms, type
+inference, execution modes, and examples.
 
-    dataframe
-    creation
-    io
-    sql
-    datatype
-    environment
+API Reference
+=============
+
+.. currentmodule:: pyflink.dataframe
+
+.. autosummary::
+    :toctree: api/
+
+    udf
diff --git a/flink-python/pyflink/dataframe/__init__.py 
b/flink-python/pyflink/dataframe/__init__.py
index 326b139efc9..50496a3fa50 100644
--- a/flink-python/pyflink/dataframe/__init__.py
+++ b/flink-python/pyflink/dataframe/__init__.py
@@ -55,6 +55,7 @@ from pyflink.dataframe.dataframe import DataFrame, 
GroupedDataFrame, col, lit
 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
 
 __all__ = [
     "DataFrame",
@@ -62,6 +63,7 @@ __all__ = [
     "DataType",
     "col",
     "lit",
+    "udf",
     "from_arrow",
     "from_dict",
     "from_pandas",
diff --git a/flink-python/pyflink/dataframe/dataframe.py 
b/flink-python/pyflink/dataframe/dataframe.py
index 55b5e1610b6..5e688d33aa1 100644
--- a/flink-python/pyflink/dataframe/dataframe.py
+++ b/flink-python/pyflink/dataframe/dataframe.py
@@ -220,10 +220,19 @@ class DataFrame:
 
             >>> import pyflink.dataframe as pf
             >>> df = pf.from_records([{"left": 1, "right": 2}])
-            >>> result = df.with_column(
+
+            >>> with_expression = df.with_column(
             ...     "total", lambda current: current["left"] + current["right"]
             ... )
 
+            >>> @pf.udf
+            ... def add(left: int, right: int) -> int:
+            ...     return left + right
+
+            >>> with_udf = df.with_column(
+            ...     "total", add(pf.col("left"), pf.col("right"))
+            ... )
+
         .. versionadded:: 2.4.0
         """
         if not isinstance(name, str):
diff --git a/flink-python/pyflink/dataframe/tests/test_udf.py 
b/flink-python/pyflink/dataframe/tests/test_udf.py
new file mode 100644
index 00000000000..12be3306aeb
--- /dev/null
+++ b/flink-python/pyflink/dataframe/tests/test_udf.py
@@ -0,0 +1,1581 @@
+################################################################################
+#  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 asyncio
+import functools
+import importlib
+import inspect
+import operator
+import types
+import unittest
+from dataclasses import dataclass
+from typing import Any, Callable, TypedDict, cast
+from unittest import mock
+
+import pandas as pd
+import pyarrow as pa
+import pyflink.dataframe as pf
+from pyflink.common import Row, RowKind
+from pyflink.table import DataTypes as TableDataTypes
+from pyflink.table.expression import Expression
+from pyflink.table.types import RowType
+from pyflink.table.udf import AsyncScalarFunction, ScalarFunction, 
TableFunction
+from pyflink.testing.test_case_utils import (
+    PyFlinkDataFrameUTTestCase,
+    PyFlinkStreamDataFrameTestCase,
+)
+
+
+def _return_dtype(declaration: Callable[..., Expression]) -> pf.DataType:
+    return cast(Any, declaration).return_dtype
+
+
+_UDF_TEST_ALIAS = int
+
+
+def _module_alias_method(self, value: int) -> "_UDF_TEST_ALIAS":
+    return value
+
+
+def _module_alias_function(value: int) -> "_UDF_TEST_ALIAS":
+    return value
+
+
+def _call_module_alias_function(value):
+    return _module_alias_function(value)
+
+
+class DataFrameUDFDeclarationTests(unittest.TestCase):
+    def test_function_declarations_return_types_and_metadata(self):
+        class Details(TypedDict):
+            label: str
+            scores: list[int]
+
+        class Result(TypedDict):
+            id: int
+            details: Details
+
+        def add_one(value: int) -> int:
+            """Add one to a value."""
+            return value + 1
+
+        def identity(value):
+            return value
+
+        def describe(value: int) -> Result:
+            return {
+                "id": value,
+                "details": {"label": str(value), "scores": [value]},
+            }
+
+        def concrete_return_with_unresolved_input(value):
+            return value
+
+        concrete_return_with_unresolved_input.__annotations__ = {
+            "value": "UnavailableInput",
+            "return": int,
+        }
+
+        def postponed_return_with_unresolved_input(value):
+            return value
+
+        postponed_return_with_unresolved_input.__annotations__ = {
+            "value": "UnavailableInput",
+            "return": "int",
+        }
+
+        decorated: Callable[..., Expression] = pf.udf(add_one)
+
+        self.assertFalse(hasattr(pf, "DataFrameUDFWrapper"))
+        udf_module = importlib.import_module("pyflink.dataframe.udf")
+        self.assertFalse(hasattr(udf_module, "DataFrameUDFWrapper"))
+        self.assertEqual(_return_dtype(decorated), pf.DataType.int64())
+        self.assertEqual(decorated.__name__, "add_one")
+        self.assertEqual(decorated.__qualname__, add_one.__qualname__)
+        self.assertEqual(decorated.__module__, add_one.__module__)
+        self.assertEqual(decorated.__doc__, "Add one to a value.")
+        self.assertNotIn("__wrapped__", vars(decorated))
+        self.assertNotIn("__signature__", vars(decorated))
+        self.assertNotIn("__annotations__", vars(decorated))
+        wrapper_signature = inspect.signature(decorated)
+        parameters = tuple(wrapper_signature.parameters.values())
+        self.assertEqual(len(parameters), 1)
+        self.assertEqual(parameters[0].name, "args")
+        self.assertIs(parameters[0].kind, inspect.Parameter.VAR_POSITIONAL)
+        self.assertIs(parameters[0].annotation, Any)
+        self.assertIs(wrapper_signature.return_annotation, Expression)
+
+        configured: Callable[..., Expression] = pf.udf(
+            return_dtype=pf.DataType.string()
+        )(
+            lambda value: str(value)
+        )
+        direct = pf.udf(functools.partial(add_one), name="partial_add_one")
+
+        self.assertEqual(_return_dtype(configured), pf.DataType.string())
+        self.assertEqual(_return_dtype(direct), pf.DataType.int64())
+        self.assertEqual(direct.__name__, "partial_add_one")
+        with mock.patch.object(
+            pf.DataType,
+            "_from_sql",
+            return_value=pf.DataType.int64(),
+        ) as from_sql:
+            sql_typed = pf.udf(identity, return_dtype="BIGINT")
+        self.assertEqual(_return_dtype(sql_typed), pf.DataType.int64())
+        from_sql.assert_called_once_with("BIGINT")
+
+        expected_result_dtype = pf.DataType.struct(
+            {
+                "id": pf.DataType.int64(),
+                "details": pf.DataType.struct(
+                    {
+                        "label": pf.DataType.string(),
+                        "scores": pf.DataType.list(pf.DataType.int64()),
+                    }
+                ),
+            }
+        )
+        declarations = [
+            (
+                "Python type",
+                lambda: pf.udf(identity, return_dtype=int),
+                pf.DataType.int64(),
+            ),
+            (
+                "nested TypedDict annotation",
+                lambda: pf.udf(describe),
+                expected_result_dtype,
+            ),
+            (
+                "explicit nested TypedDict",
+                lambda: pf.udf(identity, return_dtype=Result),
+                expected_result_dtype,
+            ),
+            (
+                "concrete return with unresolved input",
+                lambda: pf.udf(concrete_return_with_unresolved_input),
+                pf.DataType.int64(),
+            ),
+            (
+                "postponed return with unresolved input",
+                lambda: pf.udf(postponed_return_with_unresolved_input),
+                pf.DataType.int64(),
+            ),
+        ]
+        for case_name, declare, expected in declarations:
+            with self.subTest(case=case_name):
+                self.assertEqual(_return_dtype(declare()), expected)
+
+    def test_callable_classes_and_instances_infer_from_invocation_method(self):
+        plain_constructor_calls = []
+        scalar_constructor_calls = []
+
+        class AddOne:
+            def __init__(self):
+                plain_constructor_calls.append("AddOne")
+
+            def __call__(self, value: int) -> int:
+                return value + 1
+
+        class AddOffset:
+            def __init__(self, offset):
+                self.offset = offset
+
+            def __call__(self, value: int) -> int:
+                return value + self.offset
+
+        class NamedCallable:
+            __name__ = "configured_add"
+
+            def __call__(self, value: int) -> int:
+                return value + 1
+
+        class Double(ScalarFunction):
+            def __init__(self):
+                scalar_constructor_calls.append("Double")
+
+            def eval(self, *values: int) -> int:
+                value, = values
+                return value * 2
+
+        class AsyncDouble(AsyncScalarFunction):
+            def __init__(self):
+                scalar_constructor_calls.append("AsyncDouble")
+
+            async def eval(self, *values: int) -> int:
+                value, = values
+                return value * 2
+
+        class AddScalarOffset(ScalarFunction):
+            def __init__(self, offset):
+                self.offset = offset
+
+            def eval(self, *values: int) -> int:
+                value, = values
+                return value + self.offset
+
+        named_callable = NamedCallable()
+        double_instance = Double()
+        async_double_instance = AsyncDouble()
+        scalar_constructor_calls.clear()
+        callables = [
+            AddOne,
+            AddOffset(2),
+            named_callable,
+            Double,
+            double_instance,
+            AsyncDouble,
+            async_double_instance,
+            AddScalarOffset(2),
+        ]
+        for source in callables:
+            with self.subTest(source=source):
+                decorated = pf.udf(source)
+                self.assertEqual(_return_dtype(decorated), pf.DataType.int64())
+
+        self.assertEqual(plain_constructor_calls, [])
+        self.assertEqual(scalar_constructor_calls, [])
+        self.assertEqual(pf.udf(named_callable).__name__, "configured_add")
+
+        decorated_class = pf.udf(Double)
+        self.assertEqual(decorated_class.__qualname__, Double.__qualname__)
+
+    def test_callable_class_resolves_class_local_return_annotation(self):
+        class Describe:
+            class Output(TypedDict):
+                value: int
+
+            def __call__(self, value: int) -> "Output":
+                return {"value": value}
+
+        expected = pf.DataType.struct({"value": pf.DataType.int64()})
+        for source in (Describe, Describe()):
+            with self.subTest(source=source):
+                self.assertEqual(_return_dtype(pf.udf(source)), expected)
+
+    def test_callable_annotations_use_lexical_defining_class(self):
+        class BoundMethodOwner:
+            class Output(TypedDict):
+                value: int
+
+            def describe(self, value: int) -> "Output":
+                return {"value": value}
+
+        class InheritedMethodOwner:
+            class Output(TypedDict):
+                value: int
+
+            def __call__(self, value: int) -> "Output":
+                return {"value": value}
+
+        class InheritedCallable(InheritedMethodOwner):
+            pass
+
+        class SelfQualified:
+            class Output(TypedDict):
+                value: int
+
+            def __call__(self, value: int) -> "SelfQualified.Output":
+                return {"value": value}
+
+        expected = pf.DataType.struct({"value": pf.DataType.int64()})
+        bound_method = BoundMethodOwner().describe
+        for source in (
+            bound_method,
+            functools.partial(bound_method),
+            InheritedCallable,
+            SelfQualified,
+        ):
+            with self.subTest(source=source):
+                self.assertEqual(_return_dtype(pf.udf(source)), expected)
+
+        class PandasCallable:
+            Batch = pd.Series
+
+            def __call__(self, values: "Batch") -> int:
+                return len(values)
+
+        pandas_declaration = pf.udf(PandasCallable, return_dtype=int)
+        self.assertEqual(pandas_declaration._func_type, "pandas")
+
+        class ReceivingCallable:
+            _UDF_TEST_ALIAS = str
+            __call__ = _module_alias_method
+
+        self.assertEqual(
+            _return_dtype(pf.udf(ReceivingCallable)), pf.DataType.int64()
+        )
+
+        class OverriddenScalarFunction(ScalarFunction):
+            _UDF_TEST_ALIAS = str
+
+            def eval(self, *values: int) -> str:
+                value, = values
+                return str(value)
+
+        overridden = OverriddenScalarFunction()
+        overridden.eval = types.MethodType(_module_alias_method, overridden)
+        self.assertEqual(
+            _return_dtype(pf.udf(overridden)), pf.DataType.int64()
+        )
+
+    def test_wrapped_callable_annotations_and_partial_validation(self):
+        def add(value: int, amount: int = 1) -> int:
+            return value + amount
+
+        def pandas_identity(values: pd.Series) -> pd.Series:
+            return values
+
+        class WrappedCallableClass:
+            @functools.wraps(pandas_identity)
+            def __call__(self, *args, **kwargs):
+                return pandas_identity(*args, **kwargs)
+
+        class WrappedClassMethodCallableClass:
+            @classmethod
+            @functools.wraps(pandas_identity)
+            def __call__(cls, *args, **kwargs):
+                return pandas_identity(*args, **kwargs)
+
+        class WrappedScalarFunction(ScalarFunction):
+            @functools.wraps(pandas_identity)
+            def eval(self, *args, **kwargs):
+                return pandas_identity(*args, **kwargs)
+
+        with self.assertRaisesRegex(
+            TypeError, "Invalid functools.partial UDF 'add'.*unexpected 
keyword"
+        ):
+            pf.udf(functools.partial(add, missing=1))
+
+        uninspectable = pf.udf(operator.itemgetter(0), return_dtype=int)
+        self.assertEqual(_return_dtype(uninspectable), pf.DataType.int64())
+
+        wrapped_callable_instance = WrappedCallableClass()
+        for source in (
+            WrappedCallableClass,
+            wrapped_callable_instance,
+            wrapped_callable_instance.__call__,
+            WrappedClassMethodCallableClass,
+            WrappedClassMethodCallableClass(),
+            WrappedScalarFunction,
+            WrappedScalarFunction(),
+        ):
+            with self.subTest(wrapped_source=source):
+                declaration = pf.udf(
+                    source, return_dtype=pf.DataType.int64()
+                )
+                self.assertEqual(declaration._func_type, "pandas")
+
+        cross_namespace_wrapper = types.FunctionType(
+            _call_module_alias_function.__code__,
+            {
+                "_module_alias_function": _module_alias_function,
+                "_UDF_TEST_ALIAS": str,
+            },
+        )
+        functools.update_wrapper(cross_namespace_wrapper, 
_module_alias_function)
+        self.assertEqual(
+            _return_dtype(pf.udf(cross_namespace_wrapper)),
+            pf.DataType.int64(),
+        )
+
+    def test_func_type_resolution_and_async_detection(self):
+        def method_decorator(method):
+            @functools.wraps(method)
+            def wrapper(*args, **kwargs):
+                return method(*args, **kwargs)
+
+            return wrapper
+
+        def pandas_add_one(values: pd.Series) -> pd.Series:
+            return values + 1
+
+        def with_pandas_context(context: pd.Series, value: int) -> int:
+            return value
+
+        def pandas_forward_reference(values):
+            return values
+
+        pandas_forward_reference.__annotations__["values"] = "pandas.Series"
+
+        def pandas_with_unresolved_annotation(
+            values: pd.Series, context
+        ) -> pd.Series:
+            return values
+
+        pandas_with_unresolved_annotation.__annotations__[
+            "context"
+        ] = "UnavailableContext"
+
+        def pandas_after_missing_attribute(
+            context: Any, values: pd.Series
+        ) -> int:
+            return len(values)
+
+        pandas_after_missing_attribute.__annotations__[
+            "context"
+        ] = "pd.Missing"
+
+        def only_missing_attribute(context: Any) -> int:
+            return 1
+
+        only_missing_attribute.__annotations__["context"] = "pd.Missing"
+
+        def mixed(values: pd.Series, offset: int):
+            return values + offset
+
+        def arrow_add_one(values: pa.Array) -> pa.Array:
+            return pa.array([value.as_py() + 1 for value in values])
+
+        async def async_add_one(value: int) -> int:
+            return value + 1
+
+        async def async_pandas(values: pd.Series) -> pd.Series:
+            return values + 1
+
+        class PandasCallable:
+            def __call__(self, values: pd.Series) -> pd.Series:
+                return values + 1
+
+        class PandasScalarFunction(ScalarFunction):
+            def eval(self, *values: pd.Series) -> pd.Series:
+                value, = values
+                return value + 1
+
+        class AsyncScalarClass(AsyncScalarFunction):
+            async def eval(self, *values: int) -> int:
+                value, = values
+                return value + 1
+
+        class WrappedPandasContext:
+            @method_decorator
+            def __call__(self, context: pd.Series, value: int) -> int:
+                return value
+
+        wrapped_pandas_context = WrappedPandasContext()
+
+        declarations = [
+            (
+                "inferred pandas",
+                lambda: pf.udf(pandas_add_one, 
return_dtype=pf.DataType.int64()),
+                "pandas",
+                False,
+            ),
+            (
+                "bound pandas annotation is ignored",
+                lambda: pf.udf(
+                    functools.partial(with_pandas_context, pd.Series([1])),
+                ),
+                "general",
+                False,
+            ),
+            (
+                "bound wrapped pandas annotation is ignored",
+                lambda: pf.udf(
+                    functools.partial(
+                        wrapped_pandas_context.__call__, pd.Series([1])
+                    ),
+                ),
+                "general",
+                False,
+            ),
+            (
+                "pandas forward reference",
+                lambda: pf.udf(
+                    pandas_forward_reference,
+                    return_dtype=pf.DataType.int64(),
+                ),
+                "pandas",
+                False,
+            ),
+            (
+                "unresolved annotation does not hide pandas annotation",
+                lambda: pf.udf(
+                    pandas_with_unresolved_annotation,
+                    return_dtype=pf.DataType.int64(),
+                ),
+                "pandas",
+                False,
+            ),
+            (
+                "missing annotation attribute does not hide pandas annotation",
+                lambda: pf.udf(
+                    pandas_after_missing_attribute,
+                    return_dtype=pf.DataType.int64(),
+                ),
+                "pandas",
+                False,
+            ),
+            (
+                "missing annotation attribute falls back to general",
+                lambda: pf.udf(
+                    only_missing_attribute,
+                    return_dtype=pf.DataType.int64(),
+                ),
+                "general",
+                False,
+            ),
+            (
+                "any pandas annotation selects pandas",
+                lambda: pf.udf(mixed, return_dtype=pf.DataType.int64()),
+                "pandas",
+                False,
+            ),
+            (
+                "explicit general wins",
+                lambda: pf.udf(
+                    pandas_add_one,
+                    return_dtype=pf.DataType.int64(),
+                    func_type="general",
+                ),
+                "general",
+                False,
+            ),
+            (
+                "pyarrow annotations remain general",
+                lambda: pf.udf(arrow_add_one, 
return_dtype=pf.DataType.int64()),
+                "general",
+                False,
+            ),
+            (
+                "async general",
+                lambda: pf.udf(async_add_one),
+                "general",
+                True,
+            ),
+            (
+                "pandas callable class",
+                lambda: pf.udf(
+                    PandasCallable,
+                    return_dtype=pf.DataType.int64(),
+                ),
+                "pandas",
+                False,
+            ),
+            (
+                "pandas scalar-function class",
+                lambda: pf.udf(
+                    PandasScalarFunction,
+                    return_dtype=pf.DataType.int64(),
+                ),
+                "pandas",
+                False,
+            ),
+            (
+                "async scalar-function class",
+                lambda: pf.udf(AsyncScalarClass),
+                "general",
+                True,
+            ),
+        ]
+        for case_name, declare, expected_type, expected_async in declarations:
+            with self.subTest(case=case_name):
+                wrapped = declare()
+                self.assertEqual(wrapped._func_type, expected_type)
+                self.assertEqual(
+                    wrapped._runtime_source.is_async, expected_async
+                )
+
+        invalid_declarations = [
+            (
+                "async inferred pandas",
+                lambda: pf.udf(async_pandas, return_dtype=pf.DataType.int64()),
+                ValueError,
+                "Async scalar functions",
+            ),
+            (
+                "async explicit pandas",
+                lambda: pf.udf(
+                    async_add_one,
+                    return_dtype=pf.DataType.int64(),
+                    func_type="pandas",
+                ),
+                ValueError,
+                "Async scalar functions",
+            ),
+        ]
+        for case_name, declare, error_type, message in invalid_declarations:
+            with self.subTest(case=case_name):
+                with self.assertRaisesRegex(error_type, message):
+                    declare()
+
+    def test_sync_wrapper_around_async_target_is_rejected(self):
+        async def async_add_one(value: int) -> int:
+            return value + 1
+
+        @functools.wraps(async_add_one)
+        def sync_wrapper(*args, **kwargs):
+            return async_add_one(*args, **kwargs)
+
+        with self.assertRaisesRegex(TypeError, "async def"):
+            pf.udf(sync_wrapper)
+
+    def test_sync_async_scalar_eval_is_rejected(self):
+        class SyncAsyncScalarFunction(AsyncScalarFunction):
+            def eval(self, *values: int) -> int:
+                value, = values
+                return value + 1
+
+        for source in (SyncAsyncScalarFunction, SyncAsyncScalarFunction()):
+            with self.subTest(source=source):
+                with self.assertRaisesRegex(
+                    TypeError,
+                    "AsyncScalarFunction 'SyncAsyncScalarFunction'.*async def",
+                ):
+                    pf.udf(source, return_dtype=pf.DataType.int64())
+
+    def test_unrelated_methodtype_owner_requires_explicit_metadata(self):
+        class MethodOwner:
+            Batch = pd.Series
+
+            class Output(TypedDict):
+                value: int
+
+            def eval(self, values: "Batch") -> "Output":
+                return {"value": len(values)}
+
+        class ReplacedScalarFunction(ScalarFunction):
+            def eval(self, *values: int) -> int:
+                value, = values
+                return value
+
+        replaced = ReplacedScalarFunction()
+        replaced.eval = types.MethodType(MethodOwner.eval, replaced)
+
+        with self.assertRaisesRegex(
+            TypeError,
+            r"Cannot infer return_dtype for 'ReplacedScalarFunction' from its "
+            r"return annotation\.\nSpecify return_dtype explicitly\.",
+        ):
+            pf.udf(replaced)
+
+        return_dtype = pf.DataType.struct({"value": pf.DataType.int64()})
+        inferred_mode = pf.udf(replaced, return_dtype=return_dtype)
+        self.assertEqual(inferred_mode._func_type, "general")
+        explicit_mode = pf.udf(
+            replaced,
+            return_dtype=return_dtype,
+            func_type="pandas",
+        )
+        self.assertEqual(explicit_mode._func_type, "pandas")
+
+    def test_invalid_class_invocation_descriptors_fail_eagerly(self):
+        class CallableBase:
+            def __call__(self, value: int) -> int:
+                return value
+
+        class HiddenCallable(CallableBase):
+            __call__ = None
+
+        class ScalarBase(ScalarFunction):
+            def eval(self, *values: int) -> int:
+                value, = values
+                return value
+
+        class HiddenScalarFunction(ScalarBase):
+            eval = None
+
+        class InvalidStaticCallable:
+            __call__ = staticmethod(None)
+
+        class InvalidClassMethodCallable:
+            __call__ = classmethod(None)
+
+        invalid_classes = (
+            (HiddenCallable, "Callable class", "__call__"),
+            (HiddenScalarFunction, "Scalar UDF class", "eval"),
+            (InvalidStaticCallable, "Callable class", "__call__"),
+            (InvalidClassMethodCallable, "Callable class", "__call__"),
+        )
+        for source, source_kind, method_name in invalid_classes:
+            with self.subTest(source=source):
+                message = (
+                    rf"{source_kind} '{source.__name__}' has an unsupported "
+                    rf"{method_name} definition\.\nDefine {method_name} as an "
+                    r"instance, class, or static method\."
+                )
+                with self.assertRaisesRegex(TypeError, message):
+                    pf.udf(source, return_dtype=int)
+
+    def test_descriptor_based_callable_classes_require_instances(self):
+        class PartialMethodCallable:
+            def invoke(self, offset: int, value: int) -> int:
+                return offset + value
+
+            __call__ = functools.partialmethod(invoke, 1)
+
+        class PartialDescriptorCallable:
+            __call__ = functools.partial(lambda: 1)
+
+        for source in (PartialMethodCallable, PartialDescriptorCallable):
+            with self.subTest(class_source=source):
+                with self.assertRaisesRegex(
+                    TypeError,
+                    rf"Callable class '{source.__name__}' has an unsupported "
+                    r"__call__ definition\.\nDefine __call__ as an instance, "
+                    r"class, or static method\.",
+                ):
+                    pf.udf(source, return_dtype=int)
+
+            with self.subTest(instance_source=source):
+                declaration = pf.udf(
+                    source(), return_dtype=int, func_type="general"
+                )
+                self.assertEqual(
+                    _return_dtype(declaration), pf.DataType.int64()
+                )
+
+    def test_unresolved_typed_dict_fields_have_actionable_errors(self):
+        class Describe:
+            OuterAlias = int
+
+            class Output(TypedDict):
+                value: Any
+
+            Output.__annotations__["value"] = "OuterAlias"
+
+            def __call__(self, value: int) -> "Output":
+                return {"value": value}
+
+        with self.assertRaisesRegex(
+            TypeError,
+            r"Cannot infer return_dtype for 'Describe' from its return 
annotation\.\n"
+            r"Specify return_dtype explicitly\.",
+        ):
+            pf.udf(Describe)
+
+        with self.assertRaisesRegex(TypeError, "DataType or SQL"):
+            pf.udf(lambda value: value, return_dtype=Describe.Output)
+
+        class InvalidOutput(TypedDict):
+            value: Any
+
+        InvalidOutput.__annotations__["value"] = "list["
+
+        def invalid_output(value: int) -> InvalidOutput:
+            return {"value": value}
+
+        with self.assertRaisesRegex(
+            TypeError,
+            r"Cannot infer return_dtype for 'invalid_output' from its return "
+            r"annotation\.\nSpecify return_dtype explicitly\.",
+        ):
+            pf.udf(invalid_output)
+
+        with self.assertRaisesRegex(TypeError, "DataType or SQL"):
+            pf.udf(lambda value: value, return_dtype=InvalidOutput)
+
+    def test_malformed_forward_references_have_clean_inference_behavior(self):
+        def pandas_after_malformed(
+            context: Any, values: pd.Series
+        ) -> int:
+            return len(values)
+
+        pandas_after_malformed.__annotations__["context"] = "list["
+        self.assertEqual(
+            pf.udf(
+                pandas_after_malformed,
+                return_dtype=pf.DataType.int64(),
+            )._func_type,
+            "pandas",
+        )
+
+        def only_malformed(context: Any) -> int:
+            return 1
+
+        only_malformed.__annotations__["context"] = "list["
+        self.assertEqual(
+            pf.udf(
+                only_malformed, return_dtype=pf.DataType.int64()
+            )._func_type,
+            "general",
+        )
+
+        def malformed_return(value: int) -> int:
+            return value
+
+        malformed_return.__annotations__["return"] = "list["
+        with self.assertRaisesRegex(
+            TypeError,
+            r"Cannot infer return_dtype for 'malformed_return' from its return 
"
+            r"annotation\.\nSpecify return_dtype explicitly\.",
+        ):
+            pf.udf(malformed_return)
+
+    def test_determinism_and_name_metadata(self):
+        class NonDeterministic(ScalarFunction):
+            def eval(self, *values: int) -> int:
+                value, = values
+                return value
+
+            def is_deterministic(self):
+                return False
+
+        class DefaultDeterministic(ScalarFunction):
+            def eval(self, *values: int) -> int:
+                value, = values
+                return value
+
+        instance = NonDeterministic()
+        declarations = [
+            (
+                "matching instance metadata",
+                lambda: pf.udf(instance, deterministic=False),
+                False,
+            ),
+            ("class default", lambda: pf.udf(DefaultDeterministic), True),
+            (
+                "class matching metadata",
+                lambda: pf.udf(NonDeterministic, deterministic=False),
+                False,
+            ),
+        ]
+        for case_name, declare, expected in declarations:
+            with self.subTest(case=case_name):
+                self.assertEqual(declare()._deterministic, expected)
+
+        self.assertIs(
+            inspect.signature(pf.udf).parameters["deterministic"].default,
+            True,
+        )
+        with self.assertRaisesRegex(ValueError, "Inconsistent deterministic"):
+            pf.udf(instance)
+        self.assertTrue(pf.udf(NonDeterministic)._deterministic)
+
+        named = pf.udf(instance, deterministic=False, name="identity")
+        self.assertEqual(named.__name__, "identity")
+        self.assertEqual(named._table_udf_wrapper._name, "identity")
+
+    def test_general_structured_results_are_normalized_recursively(self):
+        from pyflink.dataframe.udf import _create_result_normalizer
+
+        class Details:
+            __slots__ = ("label", "scores")
+
+            def __init__(self, label, scores):
+                self.label = label
+                self.scores = scores
+
+        class ItemsOnly:
+            def __init__(self, items):
+                self._items = items
+
+            def items(self):
+                return self._items
+
+        class PropertyDetails:
+            def __init__(self, label, scores):
+                self._label = label
+                self.scores = scores
+
+            @property
+            def label(self):
+                return self._label.upper()
+
+        class MissingLabelDetails:
+            def __init__(self, scores):
+                self.scores = scores
+
+        class FailingPropertyDetails:
+            scores = [17]
+
+            @property
+            def label(self):
+                raise AttributeError("label lookup failed")
+
+        @dataclass
+        class Result:
+            id: int
+            details: Details
+            attributes: dict
+
+        return_dtype = pf.DataType.struct(
+            {
+                "id": pf.DataType.int64(),
+                "details": pf.DataType.struct(
+                    {
+                        "label": pf.DataType.string(),
+                        "scores": pf.DataType.list(pf.DataType.int64()),
+                    }
+                ),
+                "attributes": pf.DataType.map(
+                    pf.DataType.string(), pf.DataType.int64()
+                ),
+            }
+        )
+        table_type = return_dtype._to_table_data_type()
+        self.assertIsInstance(table_type, RowType)
+        result_normalizer = _create_result_normalizer(table_type)
+        self.assertIsNotNone(result_normalizer)
+
+        named_row = Row(
+            id=4,
+            details=Row(label="named", scores=[5]),
+            attributes={"count": 6},
+        )
+        named_row.set_row_kind(RowKind.DELETE)
+        expected_named_row = Row(
+            id=4,
+            details=Row(label="named", scores=[5]),
+            attributes={"count": 6},
+        )
+        expected_named_row.set_row_kind(RowKind.DELETE)
+
+        cases = [
+            (
+                "mapping",
+                {
+                    "id": 1,
+                    "details": {"scores": (2, 3), "ignored": "extra"},
+                    "attributes": [("answer", 42)],
+                    "ignored": "extra",
+                },
+                Row(
+                    id=1,
+                    details=Row(label=None, scores=[2, 3]),
+                    attributes={"answer": 42},
+                ),
+            ),
+            (
+                "named row",
+                named_row,
+                expected_named_row,
+            ),
+            (
+                "positional list and tuple",
+                [7, ("positional", (8, 9)), {"count": 10}],
+                Row(
+                    id=7,
+                    details=Row(label="positional", scores=[8, 9]),
+                    attributes={"count": 10},
+                ),
+            ),
+            (
+                "dataclass and attribute objects",
+                Result(
+                    id=11,
+                    details=Details(label="object", scores=[12]),
+                    attributes=ItemsOnly([("count", 13)]),
+                ),
+                Row(
+                    id=11,
+                    details=Row(label="object", scores=[12]),
+                    attributes={"count": 13},
+                ),
+            ),
+            (
+                "property attribute",
+                Result(
+                    id=14,
+                    details=PropertyDetails(label="property", scores=[15]),
+                    attributes={"count": 16},
+                ),
+                Row(
+                    id=14,
+                    details=Row(label="PROPERTY", scores=[15]),
+                    attributes={"count": 16},
+                ),
+            ),
+            (
+                "missing object attribute",
+                Result(
+                    id=18,
+                    details=MissingLabelDetails(scores=[19]),
+                    attributes={"count": 20},
+                ),
+                Row(
+                    id=18,
+                    details=Row(label=None, scores=[19]),
+                    attributes={"count": 20},
+                ),
+            ),
+        ]
+        for case_name, value, expected in cases:
+            with self.subTest(case=case_name):
+                self.assertEqual(
+                    result_normalizer(value), expected
+                )
+        with self.assertRaisesRegex(ValueError, "Expected 3 value"):
+            result_normalizer((1, 2))
+        with self.assertRaisesRegex(TypeError, "Expected a Mapping"):
+            result_normalizer(object())
+        with self.assertRaisesRegex(AttributeError, "label lookup failed"):
+            result_normalizer(
+                {
+                    "id": 21,
+                    "details": FailingPropertyDetails(),
+                    "attributes": {},
+                },
+            )
+
+    def test_invalid_declarations_fail_eagerly(self):
+        def missing_return(value):
+            return value
+
+        def unresolved_return(value):
+            return value
+
+        unresolved_return.__annotations__ = {
+            "return": "UnavailableReturn"
+        }
+
+        def pandas_identity(values: pd.Series) -> pd.Series:
+            return values
+
+        class RequiresArgument:
+            def __init__(self, value):
+                self.value = value
+
+            def __call__(self, other: int) -> int:
+                return other + self.value
+
+        class RequiresScalarArgument(ScalarFunction):
+            def __init__(self, value):
+                self.value = value
+
+            def eval(self, *values: int) -> int:
+                value, = values
+                return value + self.value
+
+        class RequiresAsyncScalarArgument(AsyncScalarFunction):
+            def __init__(self, value):
+                self.value = value
+
+            async def eval(self, *values: int) -> int:
+                value, = values
+                return value + self.value
+
+        class NotCallable:
+            pass
+
+        class NonScalarFunction(TableFunction):
+            def eval(self, value):
+                return value
+
+        class MissingCallableReturn:
+            def __call__(self, value):
+                return value
+
+        class MissingScalarReturn(ScalarFunction):
+            def eval(self, value):
+                return value
+
+        invalid_declarations = [
+            (
+                "not callable",
+                lambda: pf.udf(42, return_dtype=pf.DataType.int64()),
+                TypeError,
+                "func must be callable",
+            ),
+            (
+                "non-callable class",
+                lambda: pf.udf(NotCallable, return_dtype=pf.DataType.int64()),
+                TypeError,
+                "func must be callable",
+            ),
+            (
+                "non-scalar UDF class",
+                lambda: pf.udf(
+                    NonScalarFunction,
+                    return_dtype=pf.DataType.int64(),
+                ),
+                TypeError,
+                "func must be a scalar UDF",
+            ),
+            (
+                "missing return",
+                lambda: pf.udf(missing_return),
+                TypeError,
+                "add a return annotation",
+            ),
+            (
+                "unresolved return",
+                lambda: pf.udf(unresolved_return),
+                TypeError,
+                r"from its return annotation\.\nSpecify return_dtype 
explicitly\.",
+            ),
+            (
+                "callable class missing return",
+                lambda: pf.udf(MissingCallableReturn),
+                TypeError,
+                "add a return annotation",
+            ),
+            (
+                "scalar function class missing return",
+                lambda: pf.udf(MissingScalarReturn),
+                TypeError,
+                "add a return annotation",
+            ),
+            (
+                "Table return type",
+                lambda: pf.udf(
+                    missing_return, return_dtype=TableDataTypes.BIGINT()
+                ),
+                TypeError,
+                "return_dtype must be",
+            ),
+            (
+                "required constructor argument",
+                lambda: pf.udf(RequiresArgument),
+                TypeError,
+                "zero-argument constructor",
+            ),
+            (
+                "required scalar constructor argument",
+                lambda: pf.udf(RequiresScalarArgument),
+                TypeError,
+                "zero-argument constructor",
+            ),
+            (
+                "required async scalar constructor argument",
+                lambda: pf.udf(RequiresAsyncScalarArgument),
+                TypeError,
+                "zero-argument constructor",
+            ),
+            (
+                "invalid determinism",
+                lambda: pf.udf(
+                    missing_return,
+                    return_dtype=pf.DataType.int64(),
+                    deterministic=1,
+                ),
+                TypeError,
+                "deterministic must be",
+            ),
+            (
+                "invalid name",
+                lambda: pf.udf(
+                    missing_return,
+                    return_dtype=pf.DataType.int64(),
+                    name=1,
+                ),
+                TypeError,
+                "name must be",
+            ),
+            (
+                "empty name",
+                lambda: pf.udf(
+                    missing_return,
+                    return_dtype=pf.DataType.int64(),
+                    name="",
+                ),
+                ValueError,
+                "name must not be empty",
+            ),
+            (
+                "arrow func type",
+                lambda: pf.udf(
+                    missing_return,
+                    return_dtype=pf.DataType.int64(),
+                    func_type="arrow",
+                ),
+                ValueError,
+                "func_type must be one of",
+            ),
+            (
+                "pandas return type required",
+                lambda: pf.udf(pandas_identity),
+                TypeError,
+                "return_dtype is required",
+            ),
+        ]
+        for case_name, declare, error_type, message in invalid_declarations:
+            with self.subTest(case=case_name):
+                with self.assertRaisesRegex(error_type, message):
+                    declare()
+
+
+class DataFrameUDFAdapterTests(unittest.TestCase):
+    def test_general_result_normalizers_are_bound_by_return_type(self):
+        from pyflink.dataframe.udf import (
+            _DataFrameAsyncScalarFunctionAdapter,
+            _DataFrameScalarFunctionAdapter,
+            _UDFUsage,
+            _resolve_udf,
+        )
+
+        return_dtype = pf.DataType.struct(
+            {
+                "value": pf.DataType.int64(),
+                "labels": pf.DataType.list(pf.DataType.string()),
+            }
+        )
+
+        def describe(value):
+            return {"value": value, "labels": (str(value),)}
+
+        async def describe_async(value):
+            return {"value": value, "labels": (str(value),)}
+
+        sync_adapter = _DataFrameScalarFunctionAdapter(
+            _resolve_udf(describe).runtime_source,
+            return_dtype,
+            True,
+            _UDFUsage.EXPRESSION,
+            "general",
+        )
+        async_adapter = _DataFrameAsyncScalarFunctionAdapter(
+            _resolve_udf(describe_async).runtime_source,
+            return_dtype,
+            True,
+            _UDFUsage.EXPRESSION,
+            "general",
+        )
+        sync_adapter.open(object())
+        async_adapter.open(object())
+        expected = Row(value=3, labels=["3"])
+        self.assertEqual(sync_adapter.eval(3), expected)
+        self.assertEqual(asyncio.run(async_adapter.eval(3)), expected)
+
+        def identity(value):
+            return value
+
+        leaf_adapter = _DataFrameScalarFunctionAdapter(
+            _resolve_udf(identity).runtime_source,
+            pf.DataType.int64(),
+            True,
+            _UDFUsage.EXPRESSION,
+            "general",
+        )
+        leaf_adapter.open(object())
+        self.assertIs(leaf_adapter._invocation(), identity)
+
+    def test_scalar_function_lifecycle_and_cleanup(self):
+        from pyflink.dataframe.udf import (
+            _DataFrameAsyncScalarFunctionAdapter,
+            _DataFrameScalarFunctionAdapter,
+            _UDFUsage,
+            _resolve_udf,
+        )
+
+        events = []
+
+        def create_adapter(source, deterministic=True, async_mode=False):
+            adapter_type = (
+                _DataFrameAsyncScalarFunctionAdapter
+                if async_mode
+                else _DataFrameScalarFunctionAdapter
+            )
+            return adapter_type(
+                _resolve_udf(source).runtime_source,
+                pf.DataType.int64(),
+                deterministic,
+                _UDFUsage.EXPRESSION,
+                "general",
+            )
+
+        class LifecycleFunction(ScalarFunction):
+            def __init__(self):
+                events.append("init")
+
+            def open(self, function_context):
+                events.append(("open", function_context))
+
+            def eval(self, value):
+                return value + 1
+
+            def close(self):
+                events.append("close")
+
+        context = object()
+        adapter = create_adapter(LifecycleFunction)
+        self.assertEqual(events, [])
+
+        with self.assertRaisesRegex(RuntimeError, "before open"):
+            adapter.eval(1)
+
+        adapter.open(context)
+        self.assertEqual(adapter.eval(1), 2)
+        adapter.close()
+
+        with self.assertRaisesRegex(RuntimeError, "before open"):
+            adapter.eval(1)
+
+        adapter.open(context)
+        self.assertEqual(adapter.eval(2), 3)
+        adapter.close()
+        self.assertEqual(
+            events,
+            [
+                "init",
+                ("open", context),
+                "close",
+                "init",
+                ("open", context),
+                "close",
+            ],
+        )
+
+        failed_lifecycle_events = []
+
+        class NonDeterministicFunction(ScalarFunction):
+            def __init__(self):
+                failed_lifecycle_events.append("init")
+
+            def eval(self, value):
+                return value
+
+            def is_deterministic(self):
+                return False
+
+            def close(self):
+                failed_lifecycle_events.append("close")
+
+        mismatched_adapter = create_adapter(NonDeterministicFunction)
+        with self.assertRaisesRegex(ValueError, "Inconsistent deterministic"):
+            mismatched_adapter.open(context)
+        mismatched_adapter.close()
+        self.assertEqual(failed_lifecycle_events, ["init"])
+
+        async_events = []
+
+        class AsyncLifecycleFunction(AsyncScalarFunction):
+            def __init__(self):
+                async_events.append("init")
+
+            def open(self, function_context):
+                async_events.append(("open", function_context))
+
+            async def eval(self, value):
+                return value + 1
+
+            def close(self):
+                async_events.append("close")
+
+        async_adapter = create_adapter(
+            AsyncLifecycleFunction,
+            async_mode=True,
+        )
+        self.assertEqual(async_events, [])
+        async_adapter.open(context)
+        self.assertEqual(asyncio.run(async_adapter.eval(1)), 2)
+        async_adapter.close()
+        self.assertEqual(async_events, ["init", ("open", context), "close"])
+
+        initialization_failure_events = []
+
+        class ConstructorFailureFunction(ScalarFunction):
+            def __init__(self):
+                initialization_failure_events.append("init")
+                raise RuntimeError("constructor failed")
+
+            def eval(self, value):
+                return value
+
+        constructor_failure_adapter = 
create_adapter(ConstructorFailureFunction)
+        with self.assertRaisesRegex(RuntimeError, "constructor failed"):
+            constructor_failure_adapter.open(context)
+        constructor_failure_adapter.close()
+        self.assertEqual(initialization_failure_events, ["init"])
+
+        class OpenFailureFunction(ScalarFunction):
+            def __init__(self):
+                initialization_failure_events.append("second init")
+
+            def open(self, function_context):
+                initialization_failure_events.append("open")
+                raise RuntimeError("open failed")
+
+            def eval(self, value):
+                return value
+
+            def close(self):
+                initialization_failure_events.append("close")
+
+        open_failure_adapter = create_adapter(OpenFailureFunction)
+        with self.assertRaisesRegex(RuntimeError, "open failed"):
+            open_failure_adapter.open(context)
+        open_failure_adapter.close()
+        self.assertEqual(
+            initialization_failure_events,
+            ["init", "second init", "open"],
+        )
+
+        deferred_constructor_calls = []
+
+        class DeferredCallable:
+            def __init__(self):
+                deferred_constructor_calls.append("init")
+
+            def __call__(self, value):
+                return value + 1
+
+        deferred_adapter = create_adapter(DeferredCallable)
+        deferred_adapter.open(context)
+        self.assertEqual(deferred_adapter.eval(1), 2)
+        deferred_adapter.close()
+        deferred_adapter.open(context)
+        self.assertEqual(deferred_adapter.eval(2), 3)
+        deferred_adapter.close()
+        self.assertEqual(deferred_constructor_calls, ["init", "init"])
+
+        class FailingCloseFunction(ScalarFunction):
+            def eval(self, value):
+                return value
+
+            def close(self):
+                raise RuntimeError("close failed")
+
+        failing_adapter = create_adapter(FailingCloseFunction())
+        failing_adapter.open(context)
+        with self.assertRaisesRegex(RuntimeError, "close failed"):
+            failing_adapter.close()
+        with self.assertRaisesRegex(RuntimeError, "before open"):
+            failing_adapter.eval(1)
+
+    def test_binding_failure_closes_and_resets_deferred_scalar_class(self):
+        from pyflink.dataframe.udf import (
+            _DataFrameScalarFunctionAdapter,
+            _UDFUsage,
+            _resolve_udf,
+        )
+
+        events = []
+
+        class BindingFailureFunction(ScalarFunction):
+            def __init__(self):
+                events.append("init")
+
+            def open(self, function_context):
+                events.append("open")
+
+            def eval(self, value):
+                return value
+
+            def close(self):
+                events.append("close")
+                raise RuntimeError("close failed")
+
+        adapter = _DataFrameScalarFunctionAdapter(
+            _resolve_udf(BindingFailureFunction).runtime_source,
+            pf.DataType.int64(),
+            True,
+            _UDFUsage.MAP,
+            "general",
+        )
+        for _ in range(2):
+            with self.assertRaisesRegex(NotImplementedError, "'map'"):
+                adapter.open(object())
+            adapter.close()
+
+        self.assertEqual(
+            events,
+            ["init", "open", "close", "init", "open", "close"],
+        )
+
+
+class DataFrameUDFPlannerTests(PyFlinkDataFrameUTTestCase):
+    def test_with_columns_binds_expressions_and_resolves_output_schema(self):
+        @pf.udf(name="render_value")
+        def render(value: int, suffix: str) -> str:
+            return f"{value}{suffix}"
+
+        @pf.udf(
+            return_dtype=pf.DataType.struct(
+                {
+                    "value": pf.DataType.int64(),
+                    "tags": pf.DataType.list(pf.DataType.string()),
+                }
+            )
+        )
+        def describe(value):
+            return {"value": value, "tags": [str(value)]}
+
+        result = pf.from_records([(1,)], schema=["id"]).with_columns(
+            rendered=render(pf.col("id"), "-literal"),
+            description=describe(pf.col("id")),
+        )
+
+        self.assert_dataframe_schema(
+            result,
+            ["id", "rendered", "description"],
+            [
+                TableDataTypes.BIGINT(),
+                TableDataTypes.STRING(),
+                TableDataTypes.ROW(
+                    [
+                        TableDataTypes.FIELD("value", TableDataTypes.BIGINT()),
+                        TableDataTypes.FIELD(
+                            "tags", 
TableDataTypes.ARRAY(TableDataTypes.STRING())
+                        ),
+                    ]
+                ),
+            ],
+        )
+
+
+class DataFrameUDFITCase(PyFlinkStreamDataFrameTestCase):
+    def test_supported_scalar_udfs_in_one_job(self):
+        @dataclass
+        class Details:
+            doubled: int
+            labels: list
+
+        @pf.udf
+        async def add_two(value: int) -> int:
+            return value + 2
+
+        @pf.udf(return_dtype=pf.DataType.int64(), func_type="pandas")
+        def add_three(values: pd.Series) -> pd.Series:
+            return values + 3
+
+        @pf.udf(
+            return_dtype=pf.DataType.struct(
+                {
+                    "doubled": pf.DataType.int64(),
+                    "labels": pf.DataType.list(pf.DataType.string()),
+                }
+            )
+        )
+        def details(value):
+            return Details(doubled=value * 2, labels=[str(value)])
+
+        class DeferredCallable:
+            def __call__(self, value: int) -> int:
+                return value + 4
+
+        class OpenedScalarFunction(ScalarFunction):
+            def open(self, function_context):
+                self._increment = 5
+
+            def eval(self, *values: int) -> int:
+                value, = values
+                return value + self._increment
+
+        deferred = pf.udf(DeferredCallable)
+        opened_scalar_class = pf.udf(OpenedScalarFunction)
+
+        result = (
+            pf.from_records([(1,)], schema=["id"])
+            .with_columns(async_value=add_two(pf.col("id")))
+            .with_columns(
+                pandas_value=add_three(pf.col("id")),
+                details=details(pf.col("id")),
+                deferred_value=deferred(pf.col("id")),
+                scalar_value=opened_scalar_class(pf.col("id")),
+            )
+        )
+
+        self.assertEqual(
+            result.collect(),
+            [Row(1, 3, 4, Row(2, ["1"]), 5, 6)],
+        )
+
+
+if __name__ == "__main__":
+    unittest.main()
diff --git a/flink-python/pyflink/dataframe/udf.py 
b/flink-python/pyflink/dataframe/udf.py
new file mode 100644
index 00000000000..b5e55c3c14c
--- /dev/null
+++ b/flink-python/pyflink/dataframe/udf.py
@@ -0,0 +1,1297 @@
+################################################################################
+#  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 scalar functions for the DataFrame API."""
+
+import functools
+import inspect
+from collections.abc import Mapping
+from dataclasses import dataclass, field
+from enum import Enum
+from typing import (
+    Any,
+    Callable,
+    Dict,
+    FrozenSet,
+    Iterable,
+    List,
+    Optional,
+    Tuple,
+    Type,
+    Union,
+    cast,
+    get_type_hints,
+    overload,
+)
+
+from pyflink.common import Row
+from pyflink.dataframe.datatype import DataType
+from pyflink.table.expression import Expression
+from pyflink.table.expressions import call as table_call
+from pyflink.table.types import ArrayType, MapType, RowType
+from pyflink.table.udf import (
+    AsyncScalarFunction,
+    ScalarFunction,
+    UserDefinedFunction,
+    UserDefinedFunctionWrapper,
+    udf as table_udf,
+)
+from pyflink.util.api_stability_decorators import PublicEvolving
+
+__all__ = ["udf"]
+
+_UDFInput = Union[Callable[..., Any], ScalarFunction, AsyncScalarFunction, 
Type]
+_ActiveUDFSource = Union[Callable[..., Any], ScalarFunction, 
AsyncScalarFunction]
+_DataTypeLike = Union[DataType, Type, str]
+_UNRESOLVED_TYPE_HINT = object()
+
+
+class _UDFUsage(Enum):
+    EXPRESSION = "expression"
+    MAP = "map"
+    MAP_BATCHES = "map_batches"
+
+
+class _UDFSourceKind(Enum):
+    """How a resolved UDF source is initialized and invoked on a worker."""
+
+    DIRECT_CALLABLE = "direct_callable"
+    CALLABLE_INSTANCE = "callable_instance"
+    CALLABLE_CLASS = "callable_class"
+    SCALAR_FUNCTION_INSTANCE = "scalar_function_instance"
+    SCALAR_FUNCTION_CLASS = "scalar_function_class"
+
+    @property
+    def is_scalar_function(self) -> bool:
+        return self in (
+            _UDFSourceKind.SCALAR_FUNCTION_INSTANCE,
+            _UDFSourceKind.SCALAR_FUNCTION_CLASS,
+        )
+
+
+@dataclass(frozen=True)
+class _UDFDeclarationContext:
+    """Client-only metadata used while declaring a UDF."""
+
+    annotation_target: Callable[..., Any]
+    defining_class: Optional[Type]
+    globalns: Dict[str, Any]
+    localns: Optional[Dict[str, Any]]
+    ignored_hint_names: FrozenSet[str]
+
+
+@dataclass(frozen=True)
+class _UDFRuntimeSource:
+    """Worker-facing recipe used to initialize a UDF."""
+
+    callable_source: _UDFInput
+    kind: _UDFSourceKind
+    is_async: bool
+
+    @property
+    def default_name(self) -> str:
+        return _default_udf_name(self.callable_source)
+
+    @property
+    def constructs_on_worker(self) -> bool:
+        return self.kind in (
+            _UDFSourceKind.CALLABLE_CLASS,
+            _UDFSourceKind.SCALAR_FUNCTION_CLASS,
+        )
+
+    def validate_declared_determinism(self, declared: bool) -> None:
+        if self.kind is _UDFSourceKind.SCALAR_FUNCTION_INSTANCE:
+            actual = cast(
+                Union[ScalarFunction, AsyncScalarFunction], 
self.callable_source
+            ).is_deterministic()
+            _validate_determinism_agreement(declared, actual)
+
+    def create_worker_udf(self) -> "_WorkerUDF":
+        source = self.callable_source
+        if self.constructs_on_worker:
+            source_class = cast(Type, source)
+            source = source_class()
+            if self.kind.is_scalar_function:
+                if not isinstance(source, (ScalarFunction, 
AsyncScalarFunction)):
+                    raise TypeError(
+                        f"Scalar UDF class '{source_class.__name__}' 
constructed an "
+                        f"unsupported object of type 
'{type(source).__name__}'."
+                    )
+            elif not callable(source):
+                raise TypeError(
+                    f"Callable class '{source_class.__name__}' constructed a 
non-callable "
+                    f"object of type '{type(source).__name__}'."
+                )
+        return _WorkerUDF(cast(_ActiveUDFSource, source), self.kind)
+
+
+@dataclass(frozen=True)
+class _ResolvedUDF:
+    """A resolved declaration split into client and worker metadata."""
+
+    runtime_source: _UDFRuntimeSource
+    declaration_context: _UDFDeclarationContext
+
+
+@dataclass
+class _WorkerUDF:
+    """An initialized UDF owned by one worker adapter lifecycle."""
+
+    active_source: _ActiveUDFSource
+    kind: _UDFSourceKind
+    _lifecycle_opened: bool = field(default=False, init=False, repr=False)
+
+    def validate_deterministic(self, declared: bool) -> None:
+        if self.kind is _UDFSourceKind.SCALAR_FUNCTION_CLASS:
+            actual = cast(
+                Union[ScalarFunction, AsyncScalarFunction], self.active_source
+            ).is_deterministic()
+            _validate_determinism_agreement(declared, actual)
+
+    def open(self, function_context: Any) -> None:
+        if self.kind.is_scalar_function:
+            cast(
+                Union[ScalarFunction, AsyncScalarFunction], self.active_source
+            ).open(function_context)
+            self._lifecycle_opened = True
+
+    @property
+    def invocation(self) -> Callable[..., Any]:
+        if self.kind is _UDFSourceKind.DIRECT_CALLABLE:
+            return cast(Callable[..., Any], self.active_source)
+        if self.kind.is_scalar_function:
+            return cast(
+                Union[ScalarFunction, AsyncScalarFunction], self.active_source
+            ).eval
+        return cast(Callable[..., Any], getattr(self.active_source, 
"__call__"))
+
+    def close(self) -> None:
+        try:
+            if self._lifecycle_opened:
+                cast(
+                    Union[ScalarFunction, AsyncScalarFunction], 
self.active_source
+                ).close()
+        finally:
+            self._lifecycle_opened = False
+
+
+class _DataFrameUDFWrapper:
+    """Internal callable binding a DataFrame scalar UDF to Table 
expressions."""
+
+    _runtime_source: _UDFRuntimeSource
+    _return_dtype: DataType
+    _deterministic: bool
+    _func_type: str
+    _cached_table_udf_wrapper: Optional[UserDefinedFunctionWrapper]
+    _frozen: bool
+    __name__: str
+
+    def __init__(
+        self,
+        runtime_source: _UDFRuntimeSource,
+        return_dtype: DataType,
+        deterministic: bool,
+        name: str,
+        func_type: str,
+    ) -> None:
+        object.__setattr__(self, "_runtime_source", runtime_source)
+        object.__setattr__(self, "_return_dtype", return_dtype)
+        object.__setattr__(self, "_deterministic", deterministic)
+        object.__setattr__(self, "_func_type", func_type)
+        object.__setattr__(self, "_cached_table_udf_wrapper", None)
+
+        declaration_metadata = _unwrap_partial(runtime_source.callable_source)
+        for attribute_name in ("__module__", "__qualname__", "__doc__"):
+            try:
+                attribute_value = getattr(declaration_metadata, attribute_name)
+            except AttributeError:
+                continue
+            object.__setattr__(self, attribute_name, attribute_value)
+        object.__setattr__(self, "__name__", name)
+        object.__setattr__(self, "_frozen", True)
+
+    def __setattr__(self, name: str, value: Any) -> None:
+        if getattr(self, "_frozen", False):
+            raise AttributeError("DataFrame UDF declarations are immutable.")
+        object.__setattr__(self, name, value)
+
+    def __call__(self, *args: Any) -> Expression:
+        return table_call(self._table_udf_wrapper, *args)
+
+    @property
+    def _table_udf_wrapper(self) -> UserDefinedFunctionWrapper:
+        if self._cached_table_udf_wrapper is None:
+            object.__setattr__(
+                self,
+                "_cached_table_udf_wrapper",
+                self._create_table_udf_wrapper(_UDFUsage.EXPRESSION),
+            )
+        return cast(UserDefinedFunctionWrapper, self._cached_table_udf_wrapper)
+
+    def _create_table_udf_wrapper(
+        self, usage: _UDFUsage
+    ) -> UserDefinedFunctionWrapper:
+        adapter_type = (
+            _DataFrameAsyncScalarFunctionAdapter
+            if self._runtime_source.is_async
+            else _DataFrameScalarFunctionAdapter
+        )
+        actual_func = cast(
+            Union[ScalarFunction, AsyncScalarFunction],
+            adapter_type(
+                self._runtime_source,
+                self._return_dtype,
+                self._deterministic,
+                usage,
+                self._func_type,
+            ),
+        )
+        return cast(
+            UserDefinedFunctionWrapper,
+            table_udf(
+                actual_func,
+                result_type=self._return_dtype._to_table_data_type(),
+                deterministic=self._deterministic,
+                name=self.__name__,
+                func_type=self._func_type,
+            ),
+        )
+
+    @property
+    def return_dtype(self) -> DataType:
+        return self._return_dtype
+
+
+@overload
+def udf(
+    func: _UDFInput,
+    *,
+    return_dtype: Optional[_DataTypeLike] = ...,
+    deterministic: bool = ...,
+    name: Optional[str] = ...,
+    func_type: Optional[str] = ...,
+) -> Callable[..., Expression]:
+    ...
+
+
+@overload
+def udf(
+    func: None = ...,
+    *,
+    return_dtype: Optional[_DataTypeLike] = ...,
+    deterministic: bool = ...,
+    name: Optional[str] = ...,
+    func_type: Optional[str] = ...,
+) -> Callable[[_UDFInput], Callable[..., Expression]]:
+    ...
+
+
+@PublicEvolving()
+def udf(
+    func: Optional[_UDFInput] = None,
+    *,
+    return_dtype: Optional[_DataTypeLike] = None,
+    deterministic: bool = True,
+    name: Optional[str] = None,
+    func_type: Optional[str] = None,
+) -> Union[
+    Callable[..., Expression],
+    Callable[[_UDFInput], Callable[..., Expression]],
+]:
+    """
+    Create a scalar UDF for DataFrame expressions.
+
+    A UDF can be declared with a bare decorator, a configured decorator, or a
+    direct call. General UDFs may infer ``return_dtype`` from the return
+    annotation of the function, ``__call__``, or ``eval``. A ``TypedDict``
+    return annotation becomes a struct column::
+
+        >>> import pyflink.dataframe as pf
+
+        >>> @pf.udf
+        ... def add_one(value: int) -> int:
+        ...     return value + 1
+
+        >>> @pf.udf(return_dtype=str)
+        ... def as_text(value):
+        ...     return str(value)
+
+        >>> increment = pf.udf(
+        ...     lambda value, amount: value + amount,
+        ...     return_dtype="BIGINT",
+        ... )
+
+        >>> from typing import TypedDict
+
+        >>> class LabeledValue(TypedDict):
+        ...     value: int
+        ...     label: str
+
+        >>> @pf.udf
+        ... def describe(value: int) -> LabeledValue:
+        ...     return {"value": value, "label": str(value)}
+
+    Plain callable classes can be supplied as zero-argument class objects or
+    as configured instances. Class objects, including their ``__init__``, are
+    initialized on the TaskManager, so expensive initialization is deferred::
+
+        >>> class AddOne:
+        ...     def __call__(self, value: int) -> int:
+        ...         return value + 1
+
+        >>> add_one_from_class = pf.udf(AddOne)
+        >>> add_one_from_instance = pf.udf(AddOne())
+
+        >>> @pf.udf
+        ... class ModelInference:
+        ...     def __init__(self):
+        ...         self.model = load_model()
+        ...     def __call__(self, features: list[float]) -> float:
+        ...         return self.model.predict(features)
+
+    :class:`~pyflink.table.udf.ScalarFunction` and
+    :class:`~pyflink.table.udf.AsyncScalarFunction` class objects and instances
+    are also supported. Their logical result type is inferred from ``eval``
+    when it is not given explicitly. Class objects are initialized on the
+    TaskManager, where their ``open`` and ``close`` methods also run::
+
+        >>> from pyflink.table.udf import AsyncScalarFunction, ScalarFunction
+
+        >>> class AddOneFunction(ScalarFunction):
+        ...     def eval(self, value: int) -> int:
+        ...         return value + 1
+
+        >>> add_one_class = pf.udf(AddOneFunction)
+        >>> add_one_instance = pf.udf(AddOneFunction())
+
+        >>> class AsyncLookup(AsyncScalarFunction):
+        ...     async def eval(self, key: int) -> str:
+        ...         return await lookup(key)
+
+        >>> async_lookup = pf.udf(AsyncLookup)
+
+    Plain ``async def`` functions and callable objects with an asynchronous
+    ``__call__`` use general asynchronous execution::
+
+        >>> @pf.udf
+        ... async def async_add_one(value: int) -> int:
+        ...     return value + 1
+
+    Pandas UDFs always require an explicit logical ``return_dtype``. Each
+    ``ROW``-typed argument is received as a ``pandas.DataFrame`` with one 
column
+    per field; other arguments are received as ``pandas.Series``. A 
``ROW``-typed
+    result should be returned as a ``pandas.DataFrame``, while other results
+    should be returned as ``pandas.Series``. Pandas mode can be selected
+    explicitly, or inferred from a pandas container annotation on any unbound
+    parameter or the return value::
+
+        >>> import pandas as pd
+
+        >>> @pf.udf(return_dtype=pf.DataType.int64(), func_type="pandas")
+        ... def pandas_add_one(values):
+        ...     return values + 1
+
+        >>> @pf.udf(return_dtype=pf.DataType.int64())
+        ... def inferred_pandas_add_one(values: pd.Series) -> pd.Series:
+        ...     return values + 1
+
+    A declared UDF is called with DataFrame expressions or Python literals to
+    produce a single-column expression::
+
+        >>> df = pf.from_records([(1,), (2,)], schema=["value"])
+
+        >>> result = df.with_columns(
+        ...     next_value=add_one(pf.col("value")),
+        ...     incremented=increment(pf.col("value"), 2),
+        ... )
+
+    :param func: Function, callable object, scalar UDF instance, or 
zero-argument
+                 callable/scalar-UDF class.
+    :param return_dtype: DataFrame logical type, Python type, or SQL type 
string.
+                         General UDFs may infer it from a return annotation;
+                         pandas UDFs require it.
+    :param deterministic: Whether equal inputs always produce equal results.
+                          Must agree with scalar-function metadata.
+    :param name: Non-empty function identity used by the Table planner.
+    :param func_type: ``"general"`` or ``"pandas"``. If omitted, any unbound
+                      pandas container annotation selects pandas mode.
+    :return: A callable that accepts DataFrame expressions or Python literals 
and
+             returns an :class:`~pyflink.table.expression.Expression`, or a 
decorator
+             producing such a callable when ``func`` is omitted.
+
+    .. versionadded:: 2.4.0
+    """
+
+    def decorator(f: _UDFInput) -> Callable[..., Expression]:
+        resolved_udf = _resolve_udf(f)
+        runtime_source = resolved_udf.runtime_source
+        declaration_context = resolved_udf.declaration_context
+        actual_func_type = (
+            func_type
+            if func_type is not None
+            else _detect_func_type(declaration_context)
+        )
+        _validate_scalar_udf_options(
+            actual_func_type, return_dtype, runtime_source.is_async
+        )
+        actual_return_dtype = _infer_return_dtype(
+            declaration_context, return_dtype, runtime_source.default_name
+        )
+        if not isinstance(deterministic, bool):
+            raise TypeError("deterministic must be a bool.")
+        runtime_source.validate_declared_determinism(deterministic)
+        actual_name = runtime_source.default_name if name is None else name
+        if not isinstance(actual_name, str):
+            raise TypeError("name must be a str or None.")
+        if not actual_name:
+            raise ValueError("name must not be empty.")
+
+        return _DataFrameUDFWrapper(
+            runtime_source,
+            actual_return_dtype,
+            deterministic,
+            actual_name,
+            actual_func_type,
+        )
+
+    return decorator if func is None else decorator(func)
+
+
+# ======================== Declaration Validation ========================
+
+
+def _validate_determinism_agreement(declared: bool, actual: bool) -> None:
+    if declared != actual:
+        raise ValueError(
+            f"Inconsistent deterministic: {declared} and {actual}."
+        )
+
+
+def _validate_scalar_udf_options(
+    func_type: str,
+    return_dtype: Optional[_DataTypeLike],
+    is_async: bool,
+) -> None:
+    if func_type not in ("general", "pandas"):
+        raise ValueError(
+            f"The func_type must be one of 'general, pandas', got {func_type}."
+        )
+    if return_dtype is None and func_type == "pandas":
+        raise TypeError(
+            "return_dtype is required for pandas UDFs because pandas container 
"
+            "annotations do not describe the logical result type."
+        )
+    if is_async and func_type == "pandas":
+        raise ValueError(
+            "Async scalar functions do not support pandas func_type. "
+            "Use func_type='general'."
+        )
+
+
+# ======================== Callable Inspection and Resolution 
========================
+
+# ---- Invocation target resolution ----
+
+
+def _unwrap_partial(func: Any) -> Any:
+    while isinstance(func, functools.partial):
+        func = func.func
+    return func
+
+
+def _default_udf_name(func: _UDFInput) -> str:
+    target = _unwrap_partial(func)
+    name = getattr(target, "__name__", None)
+    return name if isinstance(name, str) else type(target).__name__
+
+
+def _get_callable_inspection_target(
+    func: Callable[..., Any],
+) -> Callable[..., Any]:
+    target = _unwrap_partial(func)
+    if callable(target) and not inspect.isroutine(target) and not 
inspect.isclass(target):
+        return cast(Callable[..., Any], getattr(target, "__call__"))
+    return cast(Callable[..., Any], target)
+
+
+def _first_parameter_name(func: Callable[..., Any]) -> Optional[str]:
+    try:
+        parameters = tuple(
+            inspect.signature(func, follow_wrapped=False).parameters.values()
+        )
+    except (TypeError, ValueError):
+        return None
+    return parameters[0].name if parameters else None
+
+
+def _resolve_class_invocation_target(
+    func_class: Type, method_name: str
+) -> Tuple[Optional[Callable[..., Any]], Optional[Type], Optional[str]]:
+    """Resolve the nearest supported invocation method without constructing a 
class."""
+    descriptor_owner = None
+    descriptor = None
+    for candidate in func_class.__mro__:
+        if method_name in candidate.__dict__:
+            descriptor_owner = candidate
+            descriptor = candidate.__dict__[method_name]
+            break
+
+    if descriptor_owner is None:
+        return None, None, None
+    if isinstance(descriptor, staticmethod):
+        target = descriptor.__func__
+        implicit_parameter_name = None
+    elif isinstance(descriptor, classmethod):
+        target = descriptor.__func__
+        implicit_parameter_name = (
+            _first_parameter_name(target) if inspect.isroutine(target) else 
None
+        )
+    elif inspect.isroutine(descriptor):
+        target = descriptor
+        implicit_parameter_name = _first_parameter_name(target)
+    else:
+        return None, descriptor_owner, None
+
+    if not callable(target) or not inspect.isroutine(target):
+        return None, descriptor_owner, None
+    return cast(Callable[..., Any], target), descriptor_owner, 
implicit_parameter_name
+
+
+def _validate_zero_argument_class(func_class: Type) -> None:
+    if inspect.isabstract(func_class):
+        raise TypeError(f"UDF class '{func_class.__name__}' must not be 
abstract.")
+    try:
+        constructor_signature = inspect.signature(func_class)
+    except (TypeError, ValueError) as exc:
+        raise TypeError(
+            f"Cannot verify that UDF class '{func_class.__name__}' has a 
zero-argument "
+            "constructor; pass a configured instance instead."
+        ) from exc
+    try:
+        constructor_signature.bind()
+    except TypeError as exc:
+        raise TypeError(
+            f"UDF class '{func_class.__name__}' must have a zero-argument 
constructor; "
+            "pass a configured instance instead."
+        ) from exc
+
+
+# ---- Annotation namespace resolution ----
+
+
+def _function_qualname(func: Callable[..., Any]) -> Optional[str]:
+    target = _unwrap_partial(func)
+    target = getattr(target, "__func__", target)
+    qualname = getattr(target, "__qualname__", None)
+    return qualname if isinstance(qualname, str) else None
+
+
+def _lexical_defining_class(
+    target: Callable[..., Any], candidate: Optional[Type] = None
+) -> Optional[Type]:
+    qualname = _function_qualname(target)
+    if qualname is None:
+        return None
+    owner_qualname, separator, _ = qualname.rpartition(".")
+    if not separator:
+        return None
+    if candidate is not None:
+        # Same-qualified-name method transplantation is indistinguishable 
because
+        # Python functions do not retain an exact defining-class identity.
+        return candidate if candidate.__qualname__ == owner_qualname else None
+
+    bound_target = _unwrap_partial(target)
+    receiver = getattr(bound_target, "__self__", None)
+    if receiver is None:
+        return None
+    receiver_class = receiver if inspect.isclass(receiver) else type(receiver)
+    return next(
+        (
+            owner
+            for owner in receiver_class.__mro__
+            if owner.__qualname__ == owner_qualname
+        ),
+        None,
+    )
+
+
+def _get_callable_globals(func: Callable[..., Any]) -> Dict[str, Any]:
+    func_globals = getattr(func, "__globals__", None)
+    if func_globals is None:
+        func_globals = getattr(
+            getattr(func, "__func__", None), "__globals__", {}
+        )
+    return cast(Dict[str, Any], func_globals)
+
+
+def _get_annotation_globals(func: Callable[..., Any]) -> Dict[str, Any]:
+    try:
+        unwrapped = inspect.unwrap(func)
+    except ValueError:
+        return _get_callable_globals(func)
+
+    annotations = getattr(func, "__annotations__", None)
+    if annotations is not None and annotations is getattr(
+        unwrapped, "__annotations__", None
+    ):
+        return _get_callable_globals(unwrapped)
+    return _get_callable_globals(func)
+
+
+# ---- Signature and declaration context assembly ----
+
+
+def _preserves_method_binding(
+    target: Callable[..., Any], defining_class: Optional[Type]
+) -> bool:
+    target = _unwrap_partial(target)
+    target = getattr(target, "__func__", target)
+    if defining_class is None or not hasattr(target, "__wrapped__"):
+        return False
+    try:
+        unwrapped_target = inspect.unwrap(target)
+    except ValueError:
+        return False
+    return _lexical_defining_class(
+        cast(Callable[..., Any], unwrapped_target), defining_class
+    ) is defining_class
+
+
+def _resolve_ignored_hint_names(
+    annotation_target: Callable[..., Any],
+    implicit_parameter_name: Optional[str],
+    partial_source: Any,
+    preserves_method_binding: bool,
+) -> FrozenSet[str]:
+    ignored_hint_names = set()
+    if implicit_parameter_name is not None:
+        ignored_hint_names.add(implicit_parameter_name)
+
+    if not isinstance(partial_source, functools.partial):
+        return frozenset(ignored_hint_names)
+
+    bound_function = getattr(annotation_target, "__func__", None)
+    is_wrapped_bound_method = bound_function is not None and hasattr(
+        bound_function, "__wrapped__"
+    )
+    uses_unbound_wrapped_signature = (
+        is_wrapped_bound_method and not preserves_method_binding
+    )
+    try:
+        partial_target_signature = inspect.signature(
+            cast(Callable[..., Any], bound_function)
+            if uses_unbound_wrapped_signature
+            else partial_source.func
+        )
+    except Exception:
+        return frozenset(ignored_hint_names)
+    try:
+        bound_arguments = partial_target_signature.bind_partial(
+            *partial_source.args, **(partial_source.keywords or {})
+        )
+    except TypeError as exc:
+        raise TypeError(
+            f"Invalid functools.partial UDF "
+            f"'{_default_udf_name(partial_source)}': {exc}."
+        ) from exc
+    ignored_hint_names.update(bound_arguments.arguments)
+    return frozenset(ignored_hint_names)
+
+
+def _create_declaration_context(
+    annotation_target: Callable[..., Any],
+    *,
+    descriptor_owner: Optional[Type] = None,
+    implicit_parameter_name: Optional[str] = None,
+    partial_source: Any = None,
+) -> _UDFDeclarationContext:
+    annotation_target = cast(
+        Callable[..., Any], _get_callable_inspection_target(annotation_target)
+    )
+    defining_class = _lexical_defining_class(
+        annotation_target, descriptor_owner
+    )
+    preserves_method_binding = _preserves_method_binding(
+        annotation_target, defining_class
+    )
+    if preserves_method_binding:
+        implicit_parameter_name = _first_parameter_name(
+            cast(Callable[..., Any], inspect.unwrap(annotation_target))
+        )
+    if implicit_parameter_name is None:
+        bound_target = _unwrap_partial(annotation_target)
+        bound_function = getattr(bound_target, "__func__", None)
+        if bound_function is not None:
+            implicit_parameter_name = _first_parameter_name(bound_function)
+
+    localns = None
+    if defining_class is not None:
+        localns = dict(vars(defining_class))
+        localns[defining_class.__name__] = defining_class
+
+    ignored_hint_names = _resolve_ignored_hint_names(
+        annotation_target,
+        implicit_parameter_name,
+        partial_source,
+        preserves_method_binding,
+    )
+    return _UDFDeclarationContext(
+        annotation_target=annotation_target,
+        defining_class=defining_class,
+        globalns=_get_annotation_globals(annotation_target),
+        localns=localns,
+        ignored_hint_names=ignored_hint_names,
+    )
+
+
+def _create_resolved_udf(
+    func: _UDFInput,
+    kind: _UDFSourceKind,
+    declaration_context: _UDFDeclarationContext,
+    *,
+    async_marker: bool = False,
+) -> _ResolvedUDF:
+    target = declaration_context.annotation_target
+    target_is_async = inspect.iscoroutinefunction(target)
+    try:
+        unwrapped_target = inspect.unwrap(target)
+    except ValueError as exc:
+        raise TypeError(
+            "Cannot inspect a UDF with a wrapper cycle."
+        ) from exc
+    if not target_is_async and inspect.iscoroutinefunction(unwrapped_target):
+        raise TypeError(
+            "A synchronous UDF wrapper cannot wrap an async target; define the 
"
+            "wrapper with async def."
+        )
+    if async_marker and not target_is_async:
+        raise TypeError(
+            f"AsyncScalarFunction '{_default_udf_name(func)}': eval must be "
+            "defined with async def."
+        )
+    is_async = target_is_async
+    return _ResolvedUDF(
+        _UDFRuntimeSource(func, kind, is_async), declaration_context
+    )
+
+
+def _resolve_udf(func: _UDFInput) -> _ResolvedUDF:
+    """Validate a UDF and resolve its declaration and runtime metadata."""
+    if isinstance(func, functools.partial) or inspect.isroutine(func):
+        declaration_context = _create_declaration_context(
+            cast(Callable[..., Any], func),
+            partial_source=func,
+        )
+        return _create_resolved_udf(
+            func, _UDFSourceKind.DIRECT_CALLABLE, declaration_context
+        )
+
+    if inspect.isclass(func):
+        if issubclass(func, UserDefinedFunction) and not issubclass(
+            func, (ScalarFunction, AsyncScalarFunction)
+        ):
+            raise TypeError(f"func must be a scalar UDF, got {func.__name__}.")
+        if issubclass(func, (ScalarFunction, AsyncScalarFunction)):
+            _validate_zero_argument_class(func)
+            target, descriptor_owner, implicit_parameter_name = (
+                _resolve_class_invocation_target(func, "eval")
+            )
+            if target is None:
+                raise TypeError(
+                    f"Scalar UDF class '{func.__name__}' has an unsupported 
eval "
+                    "definition.\nDefine eval as an instance, class, or static 
method."
+                )
+            declaration_context = _create_declaration_context(
+                target,
+                descriptor_owner=descriptor_owner,
+                implicit_parameter_name=implicit_parameter_name,
+            )
+            return _create_resolved_udf(
+                func,
+                _UDFSourceKind.SCALAR_FUNCTION_CLASS,
+                declaration_context,
+                async_marker=issubclass(func, AsyncScalarFunction),
+            )
+
+        target, descriptor_owner, implicit_parameter_name = (
+            _resolve_class_invocation_target(func, "__call__")
+        )
+        if target is None:
+            if descriptor_owner is None:
+                raise TypeError(f"func must be callable, got {func.__name__}.")
+            raise TypeError(
+                f"Callable class '{func.__name__}' has an unsupported __call__ 
"
+                "definition.\nDefine __call__ as an instance, class, or static 
method."
+            )
+        _validate_zero_argument_class(func)
+        declaration_context = _create_declaration_context(
+            target,
+            descriptor_owner=descriptor_owner,
+            implicit_parameter_name=implicit_parameter_name,
+        )
+        return _create_resolved_udf(
+            func, _UDFSourceKind.CALLABLE_CLASS, declaration_context
+        )
+
+    if isinstance(func, UserDefinedFunction) and not isinstance(
+        func, (ScalarFunction, AsyncScalarFunction)
+    ):
+        raise TypeError(f"func must be a scalar UDF, got 
{type(func).__name__}.")
+    if isinstance(func, (ScalarFunction, AsyncScalarFunction)):
+        target = func.eval
+        if not callable(target):
+            raise TypeError(
+                f"Scalar UDF instance '{type(func).__name__}': eval must be 
callable."
+            )
+        declaration_context = _create_declaration_context(
+            cast(Callable[..., Any], target),
+            partial_source=target,
+        )
+        return _create_resolved_udf(
+            func,
+            _UDFSourceKind.SCALAR_FUNCTION_INSTANCE,
+            declaration_context,
+            async_marker=isinstance(func, AsyncScalarFunction),
+        )
+
+    if not callable(func):
+        raise TypeError(f"func must be callable, got {type(func).__name__}.")
+    target = getattr(func, "__call__")
+    if not callable(target):
+        raise TypeError(
+            f"Callable instance '{type(func).__name__}': __call__ must be 
callable."
+        )
+    declaration_context = _create_declaration_context(
+        cast(Callable[..., Any], target),
+        partial_source=target,
+    )
+    return _create_resolved_udf(
+        func, _UDFSourceKind.CALLABLE_INSTANCE, declaration_context
+    )
+
+
+# ---- Annotation and type inference ----
+
+
+def _resolve_callable_annotation(
+    declaration_context: _UDFDeclarationContext,
+    annotation_name: str,
+    globalns: Optional[Dict[str, Any]] = None,
+) -> Any:
+    func = declaration_context.annotation_target
+    annotations = getattr(func, "__annotations__", {})
+    if annotation_name not in annotations:
+        return _UNRESOLVED_TYPE_HINT
+
+    def annotation_holder() -> None:
+        pass
+
+    annotation_holder.__annotations__ = {
+        annotation_name: annotations[annotation_name]
+    }
+    try:
+        return get_type_hints(
+            annotation_holder,
+            globalns=(
+                declaration_context.globalns if globalns is None else globalns
+            ),
+            localns=declaration_context.localns,
+        ).get(annotation_name, _UNRESOLVED_TYPE_HINT)
+    except (NameError, AttributeError, SyntaxError, TypeError):
+        return _UNRESOLVED_TYPE_HINT
+
+
+def _get_callable_return_type_hint(
+    declaration_context: _UDFDeclarationContext,
+) -> Any:
+    # Resolve the return annotation in isolation so an unresolvable parameter
+    # annotation does not prevent return-type inference.
+    return _resolve_callable_annotation(declaration_context, "return")
+
+
+def _infer_return_dtype(
+    declaration_context: _UDFDeclarationContext,
+    return_dtype: Optional[_DataTypeLike],
+    udf_name: str,
+) -> DataType:
+    """Infer the DataFrame return type or validate its explicit declaration."""
+    if return_dtype is not None:
+        return _convert_to_dtype(return_dtype)
+
+    annotations = getattr(
+        declaration_context.annotation_target, "__annotations__", {}
+    ) or {}
+    if "return" not in annotations:
+        raise TypeError(
+            f"Cannot infer return_dtype for '{udf_name}': add a return 
annotation "
+            "or specify return_dtype explicitly."
+        )
+
+    return_hint = _get_callable_return_type_hint(declaration_context)
+    if return_hint is _UNRESOLVED_TYPE_HINT:
+        raise TypeError(
+            f"Cannot infer return_dtype for '{udf_name}' from its return 
annotation.\n"
+            "Specify return_dtype explicitly."
+        )
+    try:
+        return _data_type_from_type_hint(return_hint)
+    except (NameError, AttributeError, SyntaxError, TypeError) as exc:
+        raise TypeError(
+            f"Cannot infer return_dtype for '{udf_name}' from its return 
annotation.\n"
+            "Specify return_dtype explicitly."
+        ) from exc
+
+
+def _convert_to_dtype(dtype_like: _DataTypeLike) -> DataType:
+    if isinstance(dtype_like, DataType):
+        return dtype_like
+    if isinstance(dtype_like, str):
+        return DataType._from_sql(dtype_like)
+    try:
+        return _data_type_from_type_hint(dtype_like)
+    except (NameError, AttributeError, SyntaxError, TypeError) as exc:
+        if _is_typed_dict(dtype_like):
+            raise TypeError(
+                "Cannot resolve return_dtype from the supplied TypedDict; use 
a "
+                "concrete DataFrame DataType or SQL type string."
+            ) from exc
+        raise TypeError(
+            "return_dtype must be a DataFrame DataType, Python type, or SQL "
+            f"type string, got {type(dtype_like).__name__}."
+        ) from exc
+
+
+def _is_typed_dict(type_hint: Any) -> bool:
+    try:
+        from typing import is_typeddict
+
+        if is_typeddict(type_hint):
+            return True
+    except ImportError:
+        pass
+    return (
+        isinstance(type_hint, type)
+        and issubclass(type_hint, dict)
+        and hasattr(type_hint, "__required_keys__")
+    )
+
+
+def _data_type_from_type_hint(type_hint: Any) -> DataType:
+    if _is_typed_dict(type_hint):
+        return DataType.struct(
+            {
+                name: _data_type_from_type_hint(field_hint)
+                for name, field_hint in get_type_hints(type_hint).items()
+            }
+        )
+    return DataType._from_type_hint(type_hint)
+
+
+def _detect_func_type(declaration_context: _UDFDeclarationContext) -> str:
+    """Detect pandas mode from an unbound pandas container annotation."""
+    hint_func = declaration_context.annotation_target
+    try:
+        import pandas as pd
+    except ImportError:
+        return "general"
+
+    pandas_types = (pd.Series, pd.DataFrame)
+    pandas_globalns = {
+        "pandas": pd,
+        "pd": pd,
+        **declaration_context.globalns,
+    }
+    for name in getattr(hint_func, "__annotations__", {}):
+        if name in declaration_context.ignored_hint_names:
+            continue
+        hint = _resolve_callable_annotation(
+            declaration_context,
+            name,
+            globalns=pandas_globalns,
+        )
+        if hint in pandas_types:
+            return "pandas"
+    return "general"
+
+
+# ======================== Worker Adapters ========================
+
+
+def _wrap_scalar_general_result(
+    func: Callable[..., Any],
+    result_normalizer: Callable[[Any], Any],
+    is_async: bool,
+) -> Callable[..., Any]:
+    if is_async:
+
+        @functools.wraps(func)
+        async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
+            return result_normalizer(await func(*args, **kwargs))
+
+        wrapper = async_wrapper
+    else:
+
+        @functools.wraps(func)
+        def sync_wrapper(*args: Any, **kwargs: Any) -> Any:
+            return result_normalizer(func(*args, **kwargs))
+
+        wrapper = sync_wrapper
+
+    if not hasattr(func, "__name__"):
+        wrapper.__name__ = type(func).__name__
+    return wrapper
+
+
+class _DataFrameUDFAdapterBase:
+    """Bind a lazy DataFrame UDF source to one worker invocation protocol."""
+
+    def __init__(
+        self,
+        runtime_source: _UDFRuntimeSource,
+        return_dtype: DataType,
+        deterministic: bool,
+        usage: _UDFUsage,
+        func_type: str,
+    ) -> None:
+        self._runtime_source = runtime_source
+        self._worker_udf: Optional[_WorkerUDF] = None
+        self._return_dtype = return_dtype if func_type == "general" else None
+        self._deterministic = deterministic
+        self._usage = usage
+        self._func_type = func_type
+        self._bound_invocation: Optional[Callable[..., Any]] = None
+        self.__name__ = runtime_source.default_name
+        self.__doc__ = getattr(runtime_source.callable_source, "__doc__", None)
+
+    def open(self, function_context: Any) -> None:
+        worker_udf = self._runtime_source.create_worker_udf()
+        try:
+            worker_udf.validate_deterministic(self._deterministic)
+            worker_udf.open(function_context)
+            self._bound_invocation = self._bind_func(worker_udf.invocation)
+            self._worker_udf = worker_udf
+        except Exception:
+            try:
+                worker_udf.close()
+            except Exception:
+                pass
+            self._bound_invocation = None
+            self._worker_udf = None
+            raise
+
+    def _bind_func(self, invoke_func: Callable[..., Any]) -> Callable[..., 
Any]:
+        if self._usage is not _UDFUsage.EXPRESSION:
+            raise NotImplementedError(
+                f"DataFrame UDF usage {self._usage.value!r} is not supported 
yet."
+            )
+        if self._func_type == "general":
+            result_normalizer = _create_result_normalizer(
+                cast(DataType, self._return_dtype)._to_table_data_type()
+            )
+            if result_normalizer is None:
+                return invoke_func
+            return _wrap_scalar_general_result(
+                invoke_func,
+                result_normalizer,
+                self._runtime_source.is_async,
+            )
+        return invoke_func
+
+    def close(self) -> None:
+        worker_udf = self._worker_udf
+        try:
+            if worker_udf is not None:
+                worker_udf.close()
+        finally:
+            self._bound_invocation = None
+            self._worker_udf = None
+
+    def is_deterministic(self) -> bool:
+        return self._deterministic
+
+    def _invocation(self) -> Callable[..., Any]:
+        if self._bound_invocation is None:
+            raise RuntimeError("DataFrame UDF was invoked before open().")
+        return self._bound_invocation
+
+
+class _DataFrameScalarFunctionAdapter(_DataFrameUDFAdapterBase, 
ScalarFunction):
+    """Synchronous terminal adapter for a bound DataFrame UDF."""
+
+    def eval(self, *args: Any) -> Any:
+        invoke_func = self._invocation()
+        if self._func_type == "pandas":
+            from pyflink.fn_execution.utils.operation_utils import (
+                check_pandas_udf_result,
+            )
+
+            return check_pandas_udf_result(invoke_func, *args)
+        return invoke_func(*args)
+
+
+class _DataFrameAsyncScalarFunctionAdapter(
+    _DataFrameUDFAdapterBase, AsyncScalarFunction
+):
+    """Asynchronous terminal adapter for a bound DataFrame UDF."""
+
+    async def eval(self, *args: Any) -> Any:
+        return await self._invocation()(*args)
+
+
+# ======================== Result Normalization ========================
+
+
+def _row_field_values(value: Any, field_names: List[str]) -> List[Any]:
+    if isinstance(value, Mapping):
+        return [value.get(field_name) for field_name in field_names]
+    if isinstance(value, Row) and hasattr(value, "_fields"):
+        field_indices: Dict[str, int] = {}
+        for index, field_name in enumerate(value._fields):
+            field_indices.setdefault(field_name, index)
+        field_values: List[Any] = []
+        for field_name in field_names:
+            if field_name not in field_indices:
+                raise ValueError(
+                    f"Field name {field_name!r} does not exist in Row fields "
+                    f"{value._fields}."
+                )
+            field_index = field_indices[field_name]
+            if field_index >= len(value):
+                raise ValueError(
+                    f"Field name {field_name!r} is declared in Row fields "
+                    f"{value._fields} but has no value."
+                )
+            field_values.append(value[field_index])
+        return field_values
+    if isinstance(value, (Row, tuple, list)):
+        field_count = len(field_names)
+        if len(value) != field_count:
+            raise ValueError(
+                f"Expected {field_count} value(s) for RowType "
+                f"{field_names}, got {len(value)}."
+            )
+        return list(value)
+    return [
+        _object_row_field_value(value, field_name, field_names)
+        for field_name in field_names
+    ]
+
+
+def _object_row_field_value(
+    value: Any, field_name: str, field_names: List[str]
+) -> Any:
+    try:
+        return getattr(value, field_name)
+    except AttributeError:
+        try:
+            inspect.getattr_static(value, field_name)
+        except AttributeError:
+            attributes = getattr(value, "__dict__", None)
+            has_slots = any("__slots__" in cls.__dict__ for cls in 
type(value).__mro__)
+            if isinstance(attributes, Mapping) or has_slots:
+                return None
+        else:
+            raise
+        raise TypeError(
+            f"Expected a Mapping, Row, tuple, list, or object with fields for 
RowType "
+            f"{field_names}, got {type(value).__name__}."
+        ) from None
+
+
+def _create_result_normalizer(
+    data_type: Any,
+) -> Optional[Callable[[Any], Any]]:
+    if isinstance(data_type, RowType):
+        field_names = data_type.field_names()
+        field_normalizers = tuple(
+            _create_result_normalizer(field.data_type) for field in data_type
+        )
+
+        def normalize_row(value: Any) -> Any:
+            if value is None:
+                return None
+            field_values = _row_field_values(value, field_names)
+            normalized_fields = [
+                field_value
+                if field_normalizer is None
+                else field_normalizer(field_value)
+                for field_value, field_normalizer in zip(
+                    field_values, field_normalizers
+                )
+            ]
+            row = Row(*normalized_fields)
+            row.set_field_names(field_names)
+            if isinstance(value, Row):
+                row.set_row_kind(value.get_row_kind())
+            return row
+
+        return normalize_row
+    if isinstance(data_type, ArrayType):
+        element_normalizer = _create_result_normalizer(data_type.element_type)
+        if element_normalizer is None:
+
+            def normalize_leaf_array(value: Any) -> Any:
+                return None if value is None else list(value)
+
+            return normalize_leaf_array
+
+        def normalize_array(value: Any) -> Any:
+            if value is None:
+                return None
+            return [element_normalizer(item) for item in value]
+
+        return normalize_array
+    if isinstance(data_type, MapType):
+        key_normalizer = _create_result_normalizer(data_type.key_type)
+        value_normalizer = _create_result_normalizer(data_type.value_type)
+
+        def normalize_map(value: Any) -> Any:
+            if value is None:
+                return None
+            items_method = getattr(value, "items", None)
+            if callable(items_method):
+                items = list(cast(Iterable[Any], items_method()))
+            else:
+                try:
+                    items = list(value)
+                except TypeError as exc:
+                    raise TypeError(
+                        f"Expected a Mapping or iterable of key/value pairs 
for "
+                        f"{data_type}, got {type(value).__name__}."
+                    ) from exc
+            if any(
+                not isinstance(item, (tuple, list)) or len(item) != 2
+                for item in items
+            ):
+                raise TypeError(
+                    f"Expected a Mapping or iterable of key/value pairs for 
{data_type}, "
+                    f"got {type(value).__name__}."
+                )
+            if any(item[0] is None for item in items):
+                raise TypeError(f"MapType keys must not be null for 
{data_type}.")
+            return {
+                key if key_normalizer is None else key_normalizer(key): (
+                    item_value
+                    if value_normalizer is None
+                    else value_normalizer(item_value)
+                )
+                for key, item_value in items
+            }
+
+        return normalize_map
+    return None

Reply via email to