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 b7a2e6d38bf [FLINK-40608][python] Extract type inference mechanism to 
shared component (#29267)
b7a2e6d38bf is described below

commit b7a2e6d38bf1778d4d30836f505a8621175ded5f
Author: Mika Naylor <[email protected]>
AuthorDate: Fri Oct 9 03:59:58 2026 +0200

    [FLINK-40608][python] Extract type inference mechanism to shared component 
(#29267)
---
 flink-python/pyflink/dataframe/datatype.py         |  78 +-----
 .../pyflink/dataframe/tests/test_datatype.py       |  25 +-
 flink-python/pyflink/dataframe/tests/test_sql.py   |  14 +-
 flink-python/pyflink/dataframe/tests/test_udf.py   |  53 ++--
 flink-python/pyflink/dataframe/tests/test_udtf.py  |  17 +-
 flink-python/pyflink/dataframe/udf.py              |  35 +--
 flink-python/pyflink/dataframe/udtf.py             |   5 +-
 flink-python/pyflink/table/tests/test_typehints.py | 276 +++++++++++++++++++++
 flink-python/pyflink/table/typehints.py            | 194 +++++++++++++++
 9 files changed, 551 insertions(+), 146 deletions(-)

diff --git a/flink-python/pyflink/dataframe/datatype.py 
b/flink-python/pyflink/dataframe/datatype.py
index d604f2c7fda..9e7802b0803 100644
--- a/flink-python/pyflink/dataframe/datatype.py
+++ b/flink-python/pyflink/dataframe/datatype.py
@@ -16,13 +16,10 @@
 # limitations under the License.
 
################################################################################
 
-import datetime
-import decimal
-import types
-from functools import partial
-from typing import Any, Callable, Dict, List, Optional, Tuple, Union, 
get_args, get_origin
+from typing import Any, Dict, List, Tuple, Union
 
 from pyflink.table.types import DataType as TableDataType, DataTypes, NullType
+from pyflink.table.typehints import _from_python_type
 from pyflink.util.api_stability_decorators import PublicEvolving
 
 __all__ = ["DataType"]
@@ -31,21 +28,6 @@ _INT_MIN = -(1 << 31)
 _INT_MAX = (1 << 31) - 1
 _BIGINT_MIN = -(1 << 63)
 _BIGINT_MAX = (1 << 63) - 1
-_PEP_604_UNION_TYPE = getattr(types, "UnionType", None)
-
-_BASIC_TYPE_HINT_FACTORIES: Dict[Any, Callable[[], TableDataType]] = {
-    bool: DataTypes.BOOLEAN,
-    int: DataTypes.BIGINT,
-    float: DataTypes.DOUBLE,
-    str: DataTypes.STRING,
-    bytes: DataTypes.BYTES,
-    bytearray: DataTypes.BYTES,
-    decimal.Decimal: partial(DataTypes.DECIMAL, 38, 18),
-    datetime.date: DataTypes.DATE,
-    datetime.time: DataTypes.TIME,
-    datetime.datetime: DataTypes.TIMESTAMP,
-    Any: DataTypes.STRING,
-}
 
 
 @PublicEvolving()
@@ -382,61 +364,7 @@ class DataType:
 
     @classmethod
     def _from_type_hint(cls, type_hint: Any) -> "DataType":
-        def infer_union_type(hint: Any, arguments: Tuple[Any, ...]) -> 
"DataType":
-            non_none_types = [
-                argument for argument in arguments if argument is not 
type(None)
-            ]
-            if len(non_none_types) == 1:
-                return infer(non_none_types[0]).nullable()
-
-            raise TypeError(
-                f"Cannot infer DataType from type hint '{hint}'. "
-                "Please specify the data type explicitly."
-            )
-
-        def infer_basic_type(hint: Any) -> Optional["DataType"]:
-            factory = _BASIC_TYPE_HINT_FACTORIES.get(hint)
-            return cls(factory()) if factory is not None else None
-
-        def infer(hint: Any) -> "DataType":
-            origin = get_origin(hint)
-            arguments = get_args(hint)
-
-            if origin is Union or (
-                _PEP_604_UNION_TYPE is not None
-                and origin is _PEP_604_UNION_TYPE
-            ):
-                return infer_union_type(hint, arguments)
-
-            if origin is list:
-                if not arguments:
-                    raise TypeError(
-                        "Cannot infer DataType from list without type 
argument. "
-                        "Use list[T], for example list[int]."
-                    )
-                return cls.list(infer(arguments[0]))
-
-            if origin is dict:
-                if len(arguments) != 2:
-                    raise TypeError(
-                        "Cannot infer DataType from dict without key and value 
type arguments. "
-                        "Use dict[K, V], for example dict[str, int]."
-                    )
-                return cls.map(
-                    infer(arguments[0]),
-                    infer(arguments[1]),
-                )
-
-            data_type = infer_basic_type(hint)
-            if data_type is not None:
-                return data_type
-
-            raise TypeError(
-                f"Cannot infer DataType from type hint '{hint}'. "
-                "Please specify the data type explicitly."
-            )
-
-        return infer(type_hint)
+        return cls(_from_python_type(type_hint))
 
     @classmethod
     def _from_sql(cls, sql_type: str) -> "DataType":
diff --git a/flink-python/pyflink/dataframe/tests/test_datatype.py 
b/flink-python/pyflink/dataframe/tests/test_datatype.py
index f129f9a5eb6..9d0eed98119 100644
--- a/flink-python/pyflink/dataframe/tests/test_datatype.py
+++ b/flink-python/pyflink/dataframe/tests/test_datatype.py
@@ -240,19 +240,28 @@ class DataTypeTests(unittest.TestCase):
             bytearray: pf.DataType.binary(),
             decimal.Decimal: pf.DataType.decimal(38, 18),
             datetime.date: pf.DataType.date(),
-            datetime.time: pf.DataType.time(),
+            datetime.time: pf.DataType.time(3),
             datetime.datetime: pf.DataType.timestamp(),
-            Any: pf.DataType.string(),
         }
 
         for python_type, expected_type in expected_types.items():
             with self.subTest(python_type=python_type):
                 self.assertEqual(
                     pf.DataType._from_type_hint(python_type),
-                    expected_type,
+                    expected_type.not_null(),
                 )
 
+        # `Any` opts out of the type system, so it stays nullable STRING.
+        self.assertEqual(
+            pf.DataType._from_type_hint(Any),
+            pf.DataType.string(),
+        )
+
     def test_from_optional_type_hint(self):
+        self.assertEqual(
+            pf.DataType._from_type_hint(int),
+            pf.DataType.int64().not_null(),
+        )
         self.assertEqual(
             pf.DataType._from_type_hint(Optional[int]),
             pf.DataType.int64(),
@@ -269,7 +278,7 @@ class DataTypeTests(unittest.TestCase):
         )
         self.assertEqual(
             pf.DataType._from_type_hint(list[int | None]),
-            pf.DataType.list(dtype=pf.DataType.int64()),
+            pf.DataType.list(dtype=pf.DataType.int64()).not_null(),
         )
         with self.assertRaises(TypeError):
             pf.DataType._from_type_hint(int | str)
@@ -277,16 +286,16 @@ class DataTypeTests(unittest.TestCase):
     def test_from_list_type_hint(self):
         self.assertEqual(
             pf.DataType._from_type_hint(list[int]),
-            pf.DataType.list(dtype=pf.DataType.int64()),
+            pf.DataType.list(dtype=pf.DataType.int64().not_null()).not_null(),
         )
 
     def test_from_dict_type_hint(self):
         self.assertEqual(
             pf.DataType._from_type_hint(dict[str, float]),
             pf.DataType.map(
-                key_type=pf.DataType.string(),
-                value_type=pf.DataType.float64(),
-            ),
+                key_type=pf.DataType.string().not_null(),
+                value_type=pf.DataType.float64().not_null(),
+            ).not_null(),
         )
 
     def test_from_type_hint_rejects_ambiguous_or_incomplete_hints(self):
diff --git a/flink-python/pyflink/dataframe/tests/test_sql.py 
b/flink-python/pyflink/dataframe/tests/test_sql.py
index be8d9033c94..c0b8cc07847 100644
--- a/flink-python/pyflink/dataframe/tests/test_sql.py
+++ b/flink-python/pyflink/dataframe/tests/test_sql.py
@@ -388,7 +388,7 @@ class SqlTests(PyFlinkDataFrameUTTestCase):
         explicit = pf.sql(query, auto_bind=False, source=source, chars=chars)
         automatic = pf.sql(query)
         self.assertEqual(explicit.columns, ["ch"])
-        self.assertEqual(explicit.schema.get_field_data_types(), 
[DataTypes.STRING()])
+        self.assertEqual(explicit.schema.get_field_data_types(), 
[DataTypes.STRING().not_null()])
         self.assertCountEqual(explicit.union_all(automatic).collect(), 
[Row("a"), Row("b")] * 2)
         self.assertNotIn("chars", self.t_env.list_user_defined_functions())
         self.assertNotIn("source", self.t_env.list_temporary_views())
@@ -402,9 +402,12 @@ class SqlTests(PyFlinkDataFrameUTTestCase):
             yield {"word": text, "size": len(text)}
 
         source = pf.from_dict({"text": ["ab"]})
-        for return_dtype in (None, "ROW<word STRING, size BIGINT>",
-                             pf.DataType.struct({"word": pf.DataType.string(),
-                                                 "size": 
pf.DataType.int64()})):
+        nullable = [DataTypes.STRING(), DataTypes.BIGINT()]
+        for return_dtype, expected_types in (
+                (None, [DataTypes.STRING().not_null(), 
DataTypes.BIGINT().not_null()]),
+                ("ROW<word STRING, size BIGINT>", nullable),
+                (pf.DataType.struct({"word": pf.DataType.string(), "size": 
pf.DataType.int64()}),
+                 nullable)):
             with self.subTest(return_dtype=return_dtype):
                 declaration = pf.udtf(expand, return_dtype=return_dtype)
                 named = pf.sql(
@@ -415,8 +418,7 @@ class SqlTests(PyFlinkDataFrameUTTestCase):
                     auto_bind=False, src=source, expand=declaration)
                 self.assertEqual(named.columns, ["word", "size"])
                 self.assertEqual(aliased.columns, ["w", "n"])
-                self.assertEqual(named.schema.get_field_data_types(),
-                                 [DataTypes.STRING(), DataTypes.BIGINT()])
+                self.assertEqual(named.schema.get_field_data_types(), 
expected_types)
                 self.assertEqual(named.union_all(aliased).collect(), 
[Row("ab", 2)] * 2)
                 self.assertNotIn("expand", 
self.t_env.list_user_defined_functions())
 
diff --git a/flink-python/pyflink/dataframe/tests/test_udf.py 
b/flink-python/pyflink/dataframe/tests/test_udf.py
index dfd0a396551..66fe1334bf0 100644
--- a/flink-python/pyflink/dataframe/tests/test_udf.py
+++ b/flink-python/pyflink/dataframe/tests/test_udf.py
@@ -24,7 +24,7 @@ import operator
 import types
 import unittest
 from dataclasses import dataclass
-from typing import Any, Callable, TypedDict, cast
+from typing import Any, Callable, Optional, TypedDict, cast
 from unittest import mock
 
 import pandas as pd
@@ -154,6 +154,9 @@ class DataFrameUDFDeclarationTests(unittest.TestCase):
             """Add one to a value."""
             return value + 1
 
+        def optional_return(value: int) -> Optional[int]:
+            return value if value % 2 == 0 else None
+
         def identity(value):
             return value
 
@@ -184,7 +187,7 @@ class DataFrameUDFDeclarationTests(unittest.TestCase):
         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(_return_dtype(decorated), 
pf.DataType.int64().not_null())
         self.assertEqual(decorated.__name__, "add_one")
         self.assertEqual(decorated.__qualname__, add_one.__qualname__)
         self.assertEqual(decorated.__module__, add_one.__module__)
@@ -208,7 +211,7 @@ class DataFrameUDFDeclarationTests(unittest.TestCase):
         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(_return_dtype(direct), pf.DataType.int64().not_null())
         self.assertEqual(direct.__name__, "partial_add_one")
         with mock.patch.object(
             pf.DataType,
@@ -221,19 +224,26 @@ class DataFrameUDFDeclarationTests(unittest.TestCase):
 
         expected_result_dtype = pf.DataType.struct(
             {
-                "id": pf.DataType.int64(),
+                "id": pf.DataType.int64().not_null(),
                 "details": pf.DataType.struct(
                     {
-                        "label": pf.DataType.string(),
-                        "scores": pf.DataType.list(pf.DataType.int64()),
+                        "label": pf.DataType.string().not_null(),
+                        "scores": pf.DataType.list(
+                            pf.DataType.int64().not_null()
+                        ).not_null(),
                     }
-                ),
+                ).not_null(),
             }
-        )
+        ).not_null()
         declarations = [
             (
                 "Python type",
                 lambda: pf.udf(identity, return_dtype=int),
+                pf.DataType.int64().not_null(),
+            ),
+            (
+                "Optional return annotation widens to nullable",
+                lambda: pf.udf(optional_return),
                 pf.DataType.int64(),
             ),
             (
@@ -249,12 +259,12 @@ class DataFrameUDFDeclarationTests(unittest.TestCase):
             (
                 "concrete return with unresolved input",
                 lambda: pf.udf(concrete_return_with_unresolved_input),
-                pf.DataType.int64(),
+                pf.DataType.int64().not_null(),
             ),
             (
                 "postponed return with unresolved input",
                 lambda: pf.udf(postponed_return_with_unresolved_input),
-                pf.DataType.int64(),
+                pf.DataType.int64().not_null(),
             ),
         ]
         for case_name, declare, expected in declarations:
@@ -326,7 +336,9 @@ class DataFrameUDFDeclarationTests(unittest.TestCase):
         for source in callables:
             with self.subTest(source=source):
                 decorated = pf.udf(source)
-                self.assertEqual(_return_dtype(decorated), pf.DataType.int64())
+                self.assertEqual(
+                    _return_dtype(decorated), pf.DataType.int64().not_null()
+                )
 
         self.assertEqual(plain_constructor_calls, [])
         self.assertEqual(scalar_constructor_calls, [])
@@ -343,7 +355,7 @@ class DataFrameUDFDeclarationTests(unittest.TestCase):
             def __call__(self, value: int) -> "Output":
                 return {"value": value}
 
-        expected = pf.DataType.struct({"value": pf.DataType.int64()})
+        expected = pf.DataType.struct({"value": 
pf.DataType.int64().not_null()}).not_null()
         for source in (Describe, Describe()):
             with self.subTest(source=source):
                 self.assertEqual(_return_dtype(pf.udf(source)), expected)
@@ -373,7 +385,7 @@ class DataFrameUDFDeclarationTests(unittest.TestCase):
             def __call__(self, value: int) -> "SelfQualified.Output":
                 return {"value": value}
 
-        expected = pf.DataType.struct({"value": pf.DataType.int64()})
+        expected = pf.DataType.struct({"value": 
pf.DataType.int64().not_null()}).not_null()
         bound_method = BoundMethodOwner().describe
         for source in (
             bound_method,
@@ -398,7 +410,8 @@ class DataFrameUDFDeclarationTests(unittest.TestCase):
             __call__ = _module_alias_method
 
         self.assertEqual(
-            _return_dtype(pf.udf(ReceivingCallable)), pf.DataType.int64()
+            _return_dtype(pf.udf(ReceivingCallable)),
+            pf.DataType.int64().not_null(),
         )
 
         class OverriddenScalarFunction(ScalarFunction):
@@ -411,7 +424,7 @@ class DataFrameUDFDeclarationTests(unittest.TestCase):
         overridden = OverriddenScalarFunction()
         overridden.eval = types.MethodType(_module_alias_method, overridden)
         self.assertEqual(
-            _return_dtype(pf.udf(overridden)), pf.DataType.int64()
+            _return_dtype(pf.udf(overridden)), pf.DataType.int64().not_null()
         )
 
     def test_wrapped_callable_annotations_and_partial_validation(self):
@@ -443,7 +456,9 @@ class DataFrameUDFDeclarationTests(unittest.TestCase):
             pf.udf(functools.partial(add, missing=1))
 
         uninspectable = pf.udf(operator.itemgetter(0), return_dtype=int)
-        self.assertEqual(_return_dtype(uninspectable), pf.DataType.int64())
+        self.assertEqual(
+            _return_dtype(uninspectable), pf.DataType.int64().not_null()
+        )
 
         wrapped_callable_instance = WrappedCallableClass()
         for source in (
@@ -471,7 +486,7 @@ class DataFrameUDFDeclarationTests(unittest.TestCase):
         functools.update_wrapper(cross_namespace_wrapper, 
_module_alias_function)
         self.assertEqual(
             _return_dtype(pf.udf(cross_namespace_wrapper)),
-            pf.DataType.int64(),
+            pf.DataType.int64().not_null(),
         )
 
     def test_func_type_resolution_and_async_detection(self):
@@ -817,7 +832,7 @@ class DataFrameUDFDeclarationTests(unittest.TestCase):
                     source(), return_dtype=int, func_type="general"
                 )
                 self.assertEqual(
-                    _return_dtype(declaration), pf.DataType.int64()
+                    _return_dtype(declaration), pf.DataType.int64().not_null()
                 )
 
     def test_unresolved_typed_dict_fields_have_actionable_errors(self):
@@ -1601,7 +1616,7 @@ class 
DataFrameUDFPlannerTests(PyFlinkDataFrameUTTestCase):
             ["id", "rendered", "description"],
             [
                 TableDataTypes.BIGINT(),
-                TableDataTypes.STRING(),
+                TableDataTypes.STRING().not_null(),
                 TableDataTypes.ROW(
                     [
                         TableDataTypes.FIELD("value", TableDataTypes.BIGINT()),
diff --git a/flink-python/pyflink/dataframe/tests/test_udtf.py 
b/flink-python/pyflink/dataframe/tests/test_udtf.py
index 80623aaaae1..711050be7c7 100644
--- a/flink-python/pyflink/dataframe/tests/test_udtf.py
+++ b/flink-python/pyflink/dataframe/tests/test_udtf.py
@@ -58,7 +58,10 @@ class DataFrameUDTFDeclarationTests(unittest.TestCase):
         def emit(value) -> Iterator[_Output]:
             yield {"value": value, "label": "ok"}
 
-        expected = pf.DataType.struct({"value": pf.DataType.int64(), "label": 
pf.DataType.string()})
+        expected = pf.DataType.struct({
+            "value": pf.DataType.int64().not_null(),
+            "label": pf.DataType.string().not_null(),
+        }).not_null()
         for declaration in (pf.udtf(emit), pf.udtf()(emit), 
pf.udtf(return_dtype=expected)(emit),
                             pf.udtf(emit, return_dtype=_Output)):
             self.assertEqual(declaration.return_dtype, expected)
@@ -71,15 +74,16 @@ class DataFrameUDTFDeclarationTests(unittest.TestCase):
                 return [value]
             emit.__annotations__ = {"return": hint}
             with self.subTest(hint=hint):
-                self.assertEqual(pf.udtf(emit).return_dtype, 
pf.DataType.int64())
+                self.assertEqual(pf.udtf(emit).return_dtype, 
pf.DataType.int64().not_null())
 
     def test_explicit_type_does_not_resolve_annotations(self):
         def emit(value):
             return [value]
         emit.__annotations__ = {"value": "UnavailableInput", "return": 
"UnavailableOutput"}
-        self.assertEqual(pf.udtf(emit, return_dtype=int).return_dtype, 
pf.DataType.int64())
+        self.assertEqual(
+            pf.udtf(emit, return_dtype=int).return_dtype, 
pf.DataType.int64().not_null())
         emit.__annotations__["return"] = Iterator[int]
-        self.assertEqual(pf.udtf(emit).return_dtype, pf.DataType.int64())
+        self.assertEqual(pf.udtf(emit).return_dtype, 
pf.DataType.int64().not_null())
 
     def test_callable_instance_and_partial(self):
         class Repeat:
@@ -543,7 +547,7 @@ class _DataFrameFlatMapTests:
         result = source.flat_map(expand)
         self.assertEqual(result.columns, ["value", "label"])
         self.assertEqual(result.schema.get_field_data_types(),
-                         [DataTypes.BIGINT(), DataTypes.STRING()])
+                         [DataTypes.BIGINT().not_null(), 
DataTypes.STRING().not_null()])
         rows = result.filter(pf.col("value") >= 0).select("label", 
"value").collect()
         self.assertCountEqual(rows, [Row("a", 0), Row("b", 0), Row("b", 1)])
 
@@ -686,7 +690,8 @@ class _DataFrameFlatMapTests:
         result = pf.from_dict({"x": [1]}).flat_map(expand)
         self.assertEqual(result.columns, ["f0"])
         self.assertEqual(result.schema.get_field_data_types(),
-                         [DataTypes.MAP(DataTypes.STRING(), 
DataTypes.BIGINT())])
+                         [DataTypes.MAP(DataTypes.STRING().not_null(),
+                                        
DataTypes.BIGINT().not_null()).not_null()])
         self.assertCountEqual(result.collect(), [Row({"value": 1}), 
Row({"value": 2})])
 
 
diff --git a/flink-python/pyflink/dataframe/udf.py 
b/flink-python/pyflink/dataframe/udf.py
index 06ad69cd6dc..e2f3955f435 100644
--- a/flink-python/pyflink/dataframe/udf.py
+++ b/flink-python/pyflink/dataframe/udf.py
@@ -43,6 +43,7 @@ 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.typehints import _is_typed_dict
 from pyflink.table.types import ArrayType, MapType, RowType
 from pyflink.table.udf import (
     AsyncScalarFunction,
@@ -319,7 +320,9 @@ def udf(
     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::
+    return annotation becomes a struct column. Inferred return types are
+    ``NOT NULL``. Annotate ``Optional[T]`` (or ``T | None``) to make the result
+    nullable::
 
         >>> import pyflink.dataframe as pf
 
@@ -971,7 +974,7 @@ def _infer_return_dtype(
             "Specify return_dtype explicitly."
         )
     try:
-        return _data_type_from_type_hint(return_hint)
+        return DataType._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"
@@ -985,7 +988,7 @@ def _convert_to_dtype(dtype_like: _DataTypeLike) -> 
DataType:
     if isinstance(dtype_like, str):
         return DataType._from_sql(dtype_like)
     try:
-        return _data_type_from_type_hint(dtype_like)
+        return DataType._from_type_hint(dtype_like)
     except (NameError, AttributeError, SyntaxError, TypeError) as exc:
         if _is_typed_dict(dtype_like):
             raise TypeError(
@@ -998,32 +1001,6 @@ def _convert_to_dtype(dtype_like: _DataTypeLike) -> 
DataType:
         ) 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 a unique vectorized mode from unbound container annotations."""
     hint_func = declaration_context.annotation_target
diff --git a/flink-python/pyflink/dataframe/udtf.py 
b/flink-python/pyflink/dataframe/udtf.py
index f1b92a1839e..60c1ed4bf71 100644
--- a/flink-python/pyflink/dataframe/udtf.py
+++ b/flink-python/pyflink/dataframe/udtf.py
@@ -37,7 +37,6 @@ from pyflink.dataframe.udf import (
     _convert_to_dtype,
     _create_declaration_context,
     _create_result_normalizer,
-    _data_type_from_type_hint,
     _get_callable_inspection_target,
     _get_callable_return_type_hint,
     _is_typed_dict,
@@ -315,12 +314,12 @@ def _infer_udtf_return_dtype(
             if not fields or Ellipsis in fields:
                 raise TypeError("UDTF tuple outputs must have a fixed number 
of fields.")
             dtype = DataType.struct([
-                (f"f{index}", _data_type_from_type_hint(field))
+                (f"f{index}", DataType._from_type_hint(field))
                 for index, field in enumerate(fields)
             ])
             has_named_fields = False
         else:
-            dtype = _data_type_from_type_hint(hint)
+            dtype = DataType._from_type_hint(hint)
             has_named_fields = isinstance(dtype._to_table_data_type(), RowType)
     table_type = dtype._to_table_data_type()
     if isinstance(table_type, RowType) and not table_type.fields:
diff --git a/flink-python/pyflink/table/tests/test_typehints.py 
b/flink-python/pyflink/table/tests/test_typehints.py
new file mode 100644
index 00000000000..deddb03055b
--- /dev/null
+++ b/flink-python/pyflink/table/tests/test_typehints.py
@@ -0,0 +1,276 @@
+################################################################################
+#  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 datetime
+import decimal
+import sys
+import unittest
+from typing import Any, Awaitable, Coroutine, List, Optional, TypedDict, Union
+
+import typing_extensions
+
+from pyflink.table.types import DataTypes
+from pyflink.table.typehints import _from_python_type, _unwrap_awaitable
+
+
+class _Point(TypedDict):
+    x: int
+    y: int
+
+
+class _Nested(TypedDict):
+    name: str
+    point: _Point
+
+
+class _WithOptional(TypedDict):
+    id: int
+    label: Optional[str]
+
+
+class _Partial(TypedDict, total=False):
+    x: int
+    y: int
+
+
+# typing.TypedDict ignores theese markers when computing optional keys prior 
to python 3.11
+class _WithNotRequired(TypedDict):
+    id: int
+    label: typing_extensions.NotRequired[str]
+    note: typing_extensions.NotRequired[Optional[str]]
+
+
+class _ExtensionsWithNotRequired(typing_extensions.TypedDict):
+    id: int
+    label: typing_extensions.NotRequired[str]
+    note: typing_extensions.NotRequired[Optional[str]]
+
+
+class _PartialWithRequired(TypedDict, total=False):
+    id: typing_extensions.Required[int]
+    label: str
+
+
+class _ExtensionsPartialWithRequired(typing_extensions.TypedDict, total=False):
+    id: typing_extensions.Required[int]
+    label: str
+
+
+class _LinkedNode(TypedDict):
+    value: int
+    next: Optional["_LinkedNode"]
+
+
+class _TreeNode(TypedDict):
+    # get_type_hints leaves forward references inside builtin generics 
unresolved before 3.10.
+    children: List["_TreeNode"]
+
+
+class _Employee(TypedDict):
+    department: Optional["_Department"]
+
+
+class _Department(TypedDict):
+    manager: Optional[_Employee]
+
+
+class FromPythonTypeTests(unittest.TestCase):
+
+    def test_basic_types_infer_as_not_null(self):
+        expected = {
+            bool: DataTypes.BOOLEAN(),
+            int: DataTypes.BIGINT(),
+            float: DataTypes.DOUBLE(),
+            str: DataTypes.STRING(),
+            bytes: DataTypes.BYTES(),
+            bytearray: DataTypes.BYTES(),
+            decimal.Decimal: DataTypes.DECIMAL(38, 18),
+            datetime.date: DataTypes.DATE(),
+            datetime.time: DataTypes.TIME(3),
+            datetime.datetime: DataTypes.TIMESTAMP(6),
+        }
+        for hint, data_type in expected.items():
+            with self.subTest(hint=hint):
+                self.assertEqual(_from_python_type(hint), data_type.not_null())
+
+    def test_any_infers_as_nullable_string(self):
+        self.assertEqual(_from_python_type(Any), DataTypes.STRING())
+
+    def test_optional_widens_to_nullable(self):
+        self.assertEqual(_from_python_type(Optional[int]), DataTypes.BIGINT())
+        self.assertEqual(_from_python_type(Optional[str]), DataTypes.STRING())
+
+    @unittest.skipIf(
+        sys.version_info < (3, 10), "PEP 604 union types require Python 3.10 
or later"
+    )
+    def test_pep_604_optional_widens_to_nullable(self):
+        self.assertEqual(_from_python_type(int | None), DataTypes.BIGINT())
+
+    def test_list_is_not_null_with_not_null_element(self):
+        self.assertEqual(
+            _from_python_type(list[int]),
+            DataTypes.ARRAY(DataTypes.BIGINT().not_null()).not_null(),
+        )
+
+    def test_list_of_optional_has_nullable_element(self):
+        self.assertEqual(
+            _from_python_type(list[Optional[int]]),
+            DataTypes.ARRAY(DataTypes.BIGINT()).not_null(),
+        )
+
+    def test_optional_list_is_nullable_with_not_null_element(self):
+        self.assertEqual(
+            _from_python_type(Optional[list[int]]),
+            DataTypes.ARRAY(DataTypes.BIGINT().not_null()),
+        )
+
+    def test_dict_is_not_null_with_not_null_key_and_value(self):
+        self.assertEqual(
+            _from_python_type(dict[str, float]),
+            DataTypes.MAP(
+                DataTypes.STRING().not_null(), DataTypes.DOUBLE().not_null()
+            ).not_null(),
+        )
+
+    def test_typed_dict_maps_to_not_null_row(self):
+        self.assertEqual(
+            _from_python_type(_Point),
+            DataTypes.ROW(
+                [
+                    DataTypes.FIELD("x", DataTypes.BIGINT().not_null()),
+                    DataTypes.FIELD("y", DataTypes.BIGINT().not_null()),
+                ]
+            ).not_null(),
+        )
+
+    def test_nested_typed_dict_field_is_not_null_row(self):
+        self.assertEqual(
+            _from_python_type(_Nested),
+            DataTypes.ROW(
+                [
+                    DataTypes.FIELD("name", DataTypes.STRING().not_null()),
+                    DataTypes.FIELD(
+                        "point",
+                        DataTypes.ROW(
+                            [
+                                DataTypes.FIELD("x", 
DataTypes.BIGINT().not_null()),
+                                DataTypes.FIELD("y", 
DataTypes.BIGINT().not_null()),
+                            ]
+                        ).not_null(),
+                    ),
+                ]
+            ).not_null(),
+        )
+
+    def test_typed_dict_optional_field_is_nullable(self):
+        self.assertEqual(
+            _from_python_type(_WithOptional),
+            DataTypes.ROW(
+                [
+                    DataTypes.FIELD("id", DataTypes.BIGINT().not_null()),
+                    DataTypes.FIELD("label", DataTypes.STRING()),
+                ]
+            ).not_null(),
+        )
+
+    def test_typed_dict_not_required_field_is_nullable(self):
+        for hint in (_WithNotRequired, _ExtensionsWithNotRequired):
+            with self.subTest(hint=hint):
+                self.assertEqual(
+                    _from_python_type(hint),
+                    DataTypes.ROW(
+                        [
+                            DataTypes.FIELD("id", 
DataTypes.BIGINT().not_null()),
+                            DataTypes.FIELD("label", DataTypes.STRING()),
+                            DataTypes.FIELD("note", DataTypes.STRING()),
+                        ]
+                    ).not_null(),
+                )
+
+    def test_non_total_typed_dict_fields_are_nullable(self):
+        self.assertEqual(
+            _from_python_type(_Partial),
+            DataTypes.ROW(
+                [
+                    DataTypes.FIELD("x", DataTypes.BIGINT()),
+                    DataTypes.FIELD("y", DataTypes.BIGINT()),
+                ]
+            ).not_null(),
+        )
+
+    def test_required_field_in_non_total_typed_dict_is_not_null(self):
+        for hint in (_PartialWithRequired, _ExtensionsPartialWithRequired):
+            with self.subTest(hint=hint):
+                self.assertEqual(
+                    _from_python_type(hint),
+                    DataTypes.ROW(
+                        [
+                            DataTypes.FIELD("id", 
DataTypes.BIGINT().not_null()),
+                            DataTypes.FIELD("label", DataTypes.STRING()),
+                        ]
+                    ).not_null(),
+                )
+
+    def test_typed_dict_resolves_in_container_position(self):
+        self.assertEqual(
+            _from_python_type(list[_Point]),
+            DataTypes.ARRAY(
+                DataTypes.ROW(
+                    [
+                        DataTypes.FIELD("x", DataTypes.BIGINT().not_null()),
+                        DataTypes.FIELD("y", DataTypes.BIGINT().not_null()),
+                    ]
+                ).not_null()
+            ).not_null(),
+        )
+
+    def test_self_referencing_typed_dict_raises(self):
+        for hint, name in (
+            (_LinkedNode, "_LinkedNode"),
+            (_TreeNode, "_TreeNode"),
+            (_Employee, "_Employee"),
+            (list[_Department], "_Department"),
+        ):
+            with self.subTest(hint=hint):
+                with self.assertRaisesRegex(TypeError, f"'{name}'.*references 
itself"):
+                    _from_python_type(hint)
+
+    def test_ambiguous_union_raises(self):
+        with self.assertRaises(TypeError):
+            _from_python_type(Union[int, str])
+
+    def test_unsupported_hints_raise(self):
+        for hint in (complex, list, dict[str]):
+            with self.subTest(hint=hint):
+                with self.assertRaises(TypeError):
+                    _from_python_type(hint)
+
+
+class UnwrapAwaitableTests(unittest.TestCase):
+
+    def test_unwraps_coroutine_and_awaitable(self):
+        self.assertIs(_unwrap_awaitable(Coroutine[Any, Any, int]), int)
+        self.assertIs(_unwrap_awaitable(Awaitable[str]), str)
+
+    def test_passes_through_non_awaitable(self):
+        self.assertIs(_unwrap_awaitable(int), int)
+        self.assertEqual(_unwrap_awaitable(Optional[int]), Optional[int])
+
+
+if __name__ == "__main__":
+    unittest.main()
diff --git a/flink-python/pyflink/table/typehints.py 
b/flink-python/pyflink/table/typehints.py
new file mode 100644
index 00000000000..f1e08783ab5
--- /dev/null
+++ b/flink-python/pyflink/table/typehints.py
@@ -0,0 +1,194 @@
+################################################################################
+#  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.
+################################################################################
+"""Shared inference from Python type hints to table :class:`DataType`s.
+
+This is the neutral core used to resolve a standard Python annotation into a
+:mod:`pyflink.table.types` ``DataType``. It is consumed by the DataFrame API 
and
+by UDF type-hint inference so both derive types from a single mapping.
+"""
+
+import collections.abc
+import datetime
+import decimal
+import types
+from functools import partial
+from typing import Any, Callable, Dict, Set, Union, get_args, get_origin, 
get_type_hints
+
+from pyflink.table.types import DataType, DataTypes
+
+_PEP_604_UNION_TYPE = getattr(types, "UnionType", None)
+_AWAITABLE_ORIGINS = (collections.abc.Coroutine, collections.abc.Awaitable)
+
+try:
+    # Prior to py 3.11 get_type_hints leaves these markers on the field type 
itself, and
+    # typing.TypedDict does not account for them in __optional_keys__
+    from typing_extensions import NotRequired, Required
+
+    _TYPED_DICT_KEY_MARKER_OPTIONALITY: Dict[Any, bool] = {NotRequired: True, 
Required: False}
+except ImportError:
+    _TYPED_DICT_KEY_MARKER_OPTIONALITY = {}
+
+_BASIC_TYPE_HINT_FACTORIES: Dict[Any, Callable[[], DataType]] = {
+    bool: DataTypes.BOOLEAN,
+    int: DataTypes.BIGINT,
+    float: DataTypes.DOUBLE,
+    str: DataTypes.STRING,
+    bytes: DataTypes.BYTES,
+    bytearray: DataTypes.BYTES,
+    decimal.Decimal: partial(DataTypes.DECIMAL, 38, 18),
+    datetime.date: DataTypes.DATE,
+    # TIME is stored at runtime as an int number of milliseconds of the day, 
so 3 is
+    # the highest fractional-second precision that survives a round trip.
+    datetime.time: partial(DataTypes.TIME, 3),
+    datetime.datetime: DataTypes.TIMESTAMP,
+}
+
+
+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 _from_python_type(type_hint: Any) -> DataType:
+    """Resolve a Python type hint into a table :class:`DataType`.
+
+    Supports the basic scalar types, ``list[T]``/``dict[K, V]`` containers, and
+    ``TypedDict`` (mapped to ``ROW``). Inferred types are ``NOT NULL``;
+    ``Optional[T]``/``T | None`` is the marker that widens a type to nullable,
+    and ``Any`` (an opt-out of the type system) stays nullable ``STRING``.
+    Raises :class:`TypeError` for hints that cannot be resolved unambiguously.
+    """
+
+    typed_dicts_in_progress: Set[Any] = set()
+
+    def infer_typed_dict(hint: Any) -> DataType:
+        if hint in typed_dicts_in_progress:
+            raise TypeError(
+                f"Cannot infer DataType from TypedDict '{hint.__name__}': it 
references "
+                "itself, which ROW types cannot represent. "
+                "Please specify the data type explicitly."
+            )
+        typed_dicts_in_progress.add(hint)
+        try:
+            return DataTypes.ROW(
+                [
+                    DataTypes.FIELD(
+                        name,
+                        infer_typed_dict_field(
+                            field_hint, name in hint.__optional_keys__
+                        ),
+                    )
+                    for name, field_hint in get_type_hints(hint).items()
+                ]
+            )
+        finally:
+            typed_dicts_in_progress.discard(hint)
+
+    def infer_typed_dict_field(hint: Any, is_optional_key: bool) -> DataType:
+        origin = get_origin(hint)
+        if origin in _TYPED_DICT_KEY_MARKER_OPTIONALITY:
+            is_optional_key = _TYPED_DICT_KEY_MARKER_OPTIONALITY[origin]
+            hint = get_args(hint)[0]
+        data_type = infer(hint)
+        return data_type.nullable() if is_optional_key else data_type
+
+    def infer_union(hint: Any, arguments) -> DataType:
+        non_none_types = [
+            argument for argument in arguments if argument is not type(None)
+        ]
+        if len(non_none_types) == 1:
+            return infer(non_none_types[0]).nullable()
+
+        raise TypeError(
+            f"Cannot infer DataType from type hint '{hint}'. "
+            "Please specify the data type explicitly."
+        )
+
+    def infer_basic(hint: Any):
+        factory = _BASIC_TYPE_HINT_FACTORIES.get(hint)
+        return factory() if factory is not None else None
+
+    def infer_not_null(hint: Any, origin: Any, arguments) -> DataType:
+        if _is_typed_dict(hint):
+            return infer_typed_dict(hint)
+
+        if origin is list:
+            if not arguments:
+                raise TypeError(
+                    "Cannot infer DataType from list without type argument. "
+                    "Use list[T], for example list[int]."
+                )
+            return DataTypes.ARRAY(infer(arguments[0]))
+
+        if origin is dict:
+            if len(arguments) != 2:
+                raise TypeError(
+                    "Cannot infer DataType from dict without key and value 
type arguments. "
+                    "Use dict[K, V], for example dict[str, int]."
+                )
+            return DataTypes.MAP(infer(arguments[0]), infer(arguments[1]))
+
+        data_type = infer_basic(hint)
+        if data_type is not None:
+            return data_type
+
+        raise TypeError(
+            f"Cannot infer DataType from type hint '{hint}'. "
+            "Please specify the data type explicitly."
+        )
+
+    def infer(hint: Any) -> DataType:
+        origin = get_origin(hint)
+        arguments = get_args(hint)
+
+        if origin is Union or (
+            _PEP_604_UNION_TYPE is not None and origin is _PEP_604_UNION_TYPE
+        ):
+            return infer_union(hint, arguments)
+
+        # `Any` opts out of the type system, so it stays nullable rather than
+        # acquiring the NOT NULL default applied to the concrete hints.
+        if hint is Any:
+            return DataTypes.STRING()
+
+        return infer_not_null(hint, origin, arguments).not_null()
+
+    return infer(type_hint)
+
+
+def _unwrap_awaitable(type_hint: Any) -> Any:
+    """Unwrap ``Coroutine[Any, Any, T]``/``Awaitable[T]`` to ``T``.
+
+    Returns the hint unchanged when it is not an awaitable wrapper, so it can 
be
+    applied unconditionally before resolving an async function's result type.
+    """
+    if get_origin(type_hint) in _AWAITABLE_ORIGINS:
+        arguments = get_args(type_hint)
+        if arguments:
+            return arguments[-1]
+    return type_hint

Reply via email to