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 51f54b34c7d [FLINK-40186][python] Introduce the basic functionality of 
Python DataFrame API
51f54b34c7d is described below

commit 51f54b34c7dd7f296c0513dbee63431617139e0c
Author: auroflow <[email protected]>
AuthorDate: Tue Jul 21 23:50:43 2026 +0800

    [FLINK-40186][python] Introduce the basic functionality of Python DataFrame 
API
    
    Generated-by: OpenAI Codex (GPT-5.6 Sol)
    
    This closes #28797.
---
 flink-python/dev/integration_test.sh               |   3 +
 flink-python/docs/reference/index.rst              |   1 +
 .../{index.rst => pyflink.dataframe/creation.rst}  |  28 +-
 .../docs/reference/pyflink.dataframe/dataframe.rst |  78 +++
 .../{index.rst => pyflink.dataframe/datatype.rst}  |  26 +-
 .../environment.rst}                               |  20 +-
 .../reference/{ => pyflink.dataframe}/index.rst    |  17 +-
 flink-python/pyflink/dataframe/__init__.py         |  60 +++
 flink-python/pyflink/dataframe/context.py          | 103 ++++
 flink-python/pyflink/dataframe/convert.py          | 270 ++++++++++
 flink-python/pyflink/dataframe/dataframe.py        | 350 +++++++++++++
 flink-python/pyflink/dataframe/datatype.py         |  85 +++
 flink-python/pyflink/dataframe/tests/__init__.py   |  17 +
 .../pyflink/dataframe/tests/test_context.py        |  93 ++++
 .../pyflink/dataframe/tests/test_convert.py        | 228 ++++++++
 .../pyflink/dataframe/tests/test_dataframe.py      | 575 +++++++++++++++++++++
 .../pyflink/dataframe/tests/test_datatype.py       |  65 +++
 flink-python/pyflink/testing/test_case_utils.py    |  50 ++
 flink-python/setup.py                              |   1 +
 flink-python/tox.ini                               |   2 +-
 20 files changed, 2040 insertions(+), 32 deletions(-)

diff --git a/flink-python/dev/integration_test.sh 
b/flink-python/dev/integration_test.sh
index c587a79f123..ba7ceb47aea 100755
--- a/flink-python/dev/integration_test.sh
+++ b/flink-python/dev/integration_test.sh
@@ -34,6 +34,9 @@ function test_all_modules() {
     # test datastream module
     test_module "datastream"
 
+    # test dataframe module
+    test_module "dataframe"
+
     # test fn_execution module
     test_module "fn_execution"
 
diff --git a/flink-python/docs/reference/index.rst 
b/flink-python/docs/reference/index.rst
index 9776d64718f..d8915461795 100644
--- a/flink-python/docs/reference/index.rst
+++ b/flink-python/docs/reference/index.rst
@@ -23,6 +23,7 @@ API Reference
 .. toctree::
     :maxdepth: 2
 
+    pyflink.dataframe/index
     pyflink.table/index
     pyflink.datastream/index
     pyflink.common/index
diff --git a/flink-python/docs/reference/index.rst 
b/flink-python/docs/reference/pyflink.dataframe/creation.rst
similarity index 66%
copy from flink-python/docs/reference/index.rst
copy to flink-python/docs/reference/pyflink.dataframe/creation.rst
index 9776d64718f..51692dab6bc 100644
--- a/flink-python/docs/reference/index.rst
+++ b/flink-python/docs/reference/pyflink.dataframe/creation.rst
@@ -16,13 +16,25 @@
     limitations under the License.
    
################################################################################
 
-=============
-API Reference
-=============
+==================
+DataFrame Creation
+==================
 
-.. toctree::
-    :maxdepth: 2
+Functions for creating DataFrames from row-oriented or column-oriented Python 
data.
 
-    pyflink.table/index
-    pyflink.datastream/index
-    pyflink.common/index
+Example::
+
+    >>> import pyflink.dataframe as pf
+    >>> users = pf.from_records([
+    ...     {"id": 1, "name": "Alice"},
+    ...     {"id": 2, "name": "Bob"},
+    ... ])
+    >>> users = pf.from_dict({"id": [1, 2], "name": ["Alice", "Bob"]})
+
+.. currentmodule:: pyflink.dataframe
+
+.. autosummary::
+    :toctree: api/
+
+    from_records
+    from_dict
diff --git a/flink-python/docs/reference/pyflink.dataframe/dataframe.rst 
b/flink-python/docs/reference/pyflink.dataframe/dataframe.rst
new file mode 100644
index 00000000000..2caa7503eba
--- /dev/null
+++ b/flink-python/docs/reference/pyflink.dataframe/dataframe.rst
@@ -0,0 +1,78 @@
+.. 
################################################################################
+     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.
+   
################################################################################
+
+=========
+DataFrame
+=========
+
+A DataFrame provides a Pythonic interface for composing data transformations.
+Transformation methods return new DataFrames and support fluent chaining.
+
+Example::
+
+    >>> import pyflink.dataframe as pf
+    >>> df = pf.from_dict({"id": [1, 2], "name": ["a", "b"]})
+    >>> result = df.select("id", "name") \
+    ...            .with_column("id_doubled", pf.col("id") * 2) \
+    ...            .filter(pf.col("id") > 0)
+
+DataFrame
+---------
+
+.. currentmodule:: pyflink.dataframe
+
+.. autosummary::
+    :toctree: api/
+
+    DataFrame
+
+Transformations
+---------------
+
+.. currentmodule:: pyflink.dataframe
+
+.. autosummary::
+    :toctree: api/
+
+    DataFrame.select
+    DataFrame.with_column
+    DataFrame.filter
+    DataFrame.__getitem__
+
+Results
+-------
+
+.. currentmodule:: pyflink.dataframe
+
+.. autosummary::
+    :toctree: api/
+
+    DataFrame.collect
+
+Expressions
+-----------
+
+Functions for constructing column references and literal expressions.
+
+.. currentmodule:: pyflink.dataframe
+
+.. autosummary::
+    :toctree: api/
+
+    col
+    lit
diff --git a/flink-python/docs/reference/index.rst 
b/flink-python/docs/reference/pyflink.dataframe/datatype.rst
similarity index 70%
copy from flink-python/docs/reference/index.rst
copy to flink-python/docs/reference/pyflink.dataframe/datatype.rst
index 9776d64718f..9d9b19b5cf5 100644
--- a/flink-python/docs/reference/index.rst
+++ b/flink-python/docs/reference/pyflink.dataframe/datatype.rst
@@ -16,13 +16,23 @@
     limitations under the License.
    
################################################################################
 
-=============
-API Reference
-=============
+==========
+Data Types
+==========
 
-.. toctree::
-    :maxdepth: 2
+Data types describe the logical types of values used by DataFrame expressions 
and operations.
 
-    pyflink.table/index
-    pyflink.datastream/index
-    pyflink.common/index
+Example::
+
+    >>> import pyflink.dataframe as pf
+    >>> identifier = pf.lit(42, pf.DataType.int64())
+    >>> name = pf.lit("Alice", pf.DataType.string())
+
+.. currentmodule:: pyflink.dataframe
+
+.. autosummary::
+    :toctree: api/
+
+    DataType
+    DataType.int64
+    DataType.string
diff --git a/flink-python/docs/reference/index.rst 
b/flink-python/docs/reference/pyflink.dataframe/environment.rst
similarity index 75%
copy from flink-python/docs/reference/index.rst
copy to flink-python/docs/reference/pyflink.dataframe/environment.rst
index 9776d64718f..e0e4d08f925 100644
--- a/flink-python/docs/reference/index.rst
+++ b/flink-python/docs/reference/pyflink.dataframe/environment.rst
@@ -16,13 +16,17 @@
     limitations under the License.
    
################################################################################
 
-=============
-API Reference
-=============
+=====================
+Execution Environment
+=====================
 
-.. toctree::
-    :maxdepth: 2
+Functions for setting, inspecting, and creating the environment used by 
DataFrame operations.
 
-    pyflink.table/index
-    pyflink.datastream/index
-    pyflink.common/index
+.. currentmodule:: pyflink.dataframe
+
+.. autosummary::
+    :toctree: api/
+
+    set_table_environment
+    get_table_environment
+    get_or_create_table_environment
diff --git a/flink-python/docs/reference/index.rst 
b/flink-python/docs/reference/pyflink.dataframe/index.rst
similarity index 83%
copy from flink-python/docs/reference/index.rst
copy to flink-python/docs/reference/pyflink.dataframe/index.rst
index 9776d64718f..765a2e5b884 100644
--- a/flink-python/docs/reference/index.rst
+++ b/flink-python/docs/reference/pyflink.dataframe/index.rst
@@ -16,13 +16,16 @@
     limitations under the License.
    
################################################################################
 
-=============
-API Reference
-=============
+==================
+PyFlink DataFrame
+==================
+
+This page gives an overview of all public PyFlink DataFrame APIs.
 
 .. toctree::
-    :maxdepth: 2
+    :maxdepth: 1
 
-    pyflink.table/index
-    pyflink.datastream/index
-    pyflink.common/index
+    dataframe
+    creation
+    datatype
+    environment
diff --git a/flink-python/pyflink/dataframe/__init__.py 
b/flink-python/pyflink/dataframe/__init__.py
new file mode 100644
index 00000000000..8ad43bcbbe2
--- /dev/null
+++ b/flink-python/pyflink/dataframe/__init__.py
@@ -0,0 +1,60 @@
+################################################################################
+#  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.
+################################################################################
+
+"""
+A DataFrame API for PyFlink.
+
+It provides a fluent interface for composing and executing data 
transformations.
+
+Example::
+
+    >>> import pyflink.dataframe as pf
+    >>> df = pf.from_records(
+    ...     [(1, "Alice", 30), (2, "Bob", 17)],
+    ...     schema=["id", "name", "age"],
+    ... )
+    >>> result = (
+    ...     df.filter(pf.col("age") >= 18)
+    ...     .with_column("age_next_year", pf.col("age") + 1)
+    ...     .select("id", "name", "age_next_year")
+    ... )
+    >>> for row in result.collect():
+    ...     print(row)
+    <Row(1, 'Alice', 31)>
+"""
+
+from pyflink.dataframe.convert import from_dict, from_records
+from pyflink.dataframe.context import (
+    get_or_create_table_environment,
+    get_table_environment,
+    set_table_environment,
+)
+from pyflink.dataframe.dataframe import DataFrame, col, lit
+from pyflink.dataframe.datatype import DataType
+
+__all__ = [
+    "DataFrame",
+    "DataType",
+    "col",
+    "lit",
+    "from_dict",
+    "from_records",
+    "set_table_environment",
+    "get_table_environment",
+    "get_or_create_table_environment",
+]
diff --git a/flink-python/pyflink/dataframe/context.py 
b/flink-python/pyflink/dataframe/context.py
new file mode 100644
index 00000000000..031c2995108
--- /dev/null
+++ b/flink-python/pyflink/dataframe/context.py
@@ -0,0 +1,103 @@
+################################################################################
+#  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.
+################################################################################
+
+from typing import Optional
+
+from pyflink.table import StreamTableEnvironment, TableEnvironment
+from pyflink.util.api_stability_decorators import PublicEvolving
+
+__all__ = [
+    "set_table_environment",
+    "get_table_environment",
+    "get_or_create_table_environment",
+]
+
+_global_table_environment: Optional[TableEnvironment] = None
+
+
+@PublicEvolving()
+def set_table_environment(t_env: Optional[TableEnvironment]) -> None:
+    """
+    Set the environment used by DataFrame operations.
+
+    :param t_env: Environment to use, or ``None`` to clear it.
+    :raises TypeError: If ``t_env`` is neither a :class:`TableEnvironment` nor 
``None``.
+
+    Example::
+
+        >>> import pyflink.dataframe as pf
+        >>> pf.set_table_environment(None)
+        >>> pf.get_table_environment() is None
+        True
+
+    .. versionadded:: 2.4.0
+    """
+    global _global_table_environment
+    if t_env is not None and not isinstance(t_env, TableEnvironment):
+        raise TypeError("t_env must be a TableEnvironment or None")
+    _global_table_environment = t_env
+
+
+@PublicEvolving()
+def get_table_environment() -> Optional[TableEnvironment]:
+    """
+    Return the environment used by DataFrame operations, if one is configured.
+
+    :return: The configured environment, or ``None``.
+
+    Example::
+
+        >>> import pyflink.dataframe as pf
+        >>> pf.set_table_environment(None)
+        >>> pf.get_table_environment() is None
+        True
+
+    .. versionadded:: 2.4.0
+    """
+    return _global_table_environment
+
+
+@PublicEvolving()
+def get_or_create_table_environment() -> TableEnvironment:
+    """
+    Return the configured environment, creating one when necessary.
+
+    The created environment is retained for subsequent DataFrame operations 
and calls to
+    :func:`get_table_environment`.
+
+    :return: The configured or newly created environment.
+
+    Example::
+
+        >>> import pyflink.dataframe as pf
+        >>> pf.set_table_environment(None)
+        >>> environment = pf.get_or_create_table_environment()
+        >>> pf.get_table_environment() is environment
+        True
+
+    .. versionadded:: 2.4.0
+    """
+    global _global_table_environment
+
+    if _global_table_environment is None:
+        from pyflink.datastream import StreamExecutionEnvironment
+
+        stream_environment = 
StreamExecutionEnvironment.get_execution_environment()
+        _global_table_environment = 
StreamTableEnvironment.create(stream_environment)
+
+    return _global_table_environment
diff --git a/flink-python/pyflink/dataframe/convert.py 
b/flink-python/pyflink/dataframe/convert.py
new file mode 100644
index 00000000000..9a78fa65ee3
--- /dev/null
+++ b/flink-python/pyflink/dataframe/convert.py
@@ -0,0 +1,270 @@
+################################################################################
+#  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.
+################################################################################
+
+from enum import Enum
+from typing import (
+    Any,
+    Collection,
+    List,
+    Mapping,
+    Optional,
+    Sequence,
+    Tuple,
+    Union,
+    cast,
+)
+
+from pyflink.dataframe.context import get_or_create_table_environment
+from pyflink.dataframe.dataframe import DataFrame
+from pyflink.util.api_stability_decorators import PublicEvolving
+
+__all__ = ["from_dict", "from_records"]
+
+_SCALAR_SEQUENCE_TYPES = (str, bytes, bytearray, memoryview)
+
+
+class _RecordType(Enum):
+    NAMED_TUPLE = "named_tuple"
+    MAPPING = "mapping"
+    SEQUENCE = "sequence"
+
+    @classmethod
+    def from_record(cls, record: Any) -> "_RecordType":
+        if isinstance(record, tuple) and isinstance(
+            getattr(record, "_fields", None), tuple
+        ):
+            return cls.NAMED_TUPLE
+        if isinstance(record, Mapping):
+            return cls.MAPPING
+        if isinstance(record, Sequence) and not isinstance(
+            record, _SCALAR_SEQUENCE_TYPES
+        ):
+            return cls.SEQUENCE
+        raise TypeError(
+            "record must be a mapping or a sequence of values, "
+            "such as a list or tuple"
+        )
+
+    def validate(self, record: Any) -> None:
+        try:
+            record_type = self.from_record(record)
+        except TypeError:
+            record_type = None
+
+        if record_type is self:
+            return
+        # Treat named tuples as tuples when validating sequence records.
+        if self is _RecordType.SEQUENCE and record_type is 
_RecordType.NAMED_TUPLE:
+            return
+
+        raise TypeError(f"record must be a {self.value.replace('_', ' ')}")
+
+    def field_names(self, record: Any) -> Collection[str]:
+        if self is _RecordType.NAMED_TUPLE:
+            return cast(Tuple[str, ...], getattr(record, "_fields"))
+        if self is _RecordType.MAPPING:
+            return cast(Mapping[str, Any], record).keys()
+        raise TypeError("sequence records do not have named fields")
+
+    def normalize_record(
+        self,
+        record: Any,
+        schema: List[str],
+        require_exact_fields: bool,
+    ) -> Tuple[Any, ...]:
+        if self is _RecordType.SEQUENCE:
+            row = tuple(record)
+            if require_exact_fields and len(row) != len(schema):
+                raise ValueError(
+                    f"record has {len(row)} values but schema has 
{len(schema)} fields"
+                )
+            return row
+        record_fields = self.field_names(record)
+        for name in schema:
+            if name not in record_fields:
+                raise ValueError(f"record is missing schema field {name!r}")
+        if require_exact_fields and len(record_fields) != len(schema):
+            extra_fields = [name for name in record_fields if name not in 
schema]
+            raise ValueError(
+                f"record has fields not present in schema: {extra_fields!r}"
+            )
+        if self is _RecordType.MAPPING:
+            field_values = cast(Mapping[str, Any], record)
+            return tuple(field_values[name] for name in schema)
+        return tuple(getattr(record, name) for name in schema)
+
+
+def _validate_schema(schema: List[str]) -> None:
+    if not isinstance(schema, list) or any(not isinstance(name, str) for name 
in schema):
+        raise TypeError("schema must be a list of strings")
+    if not schema:
+        raise ValueError("schema must not be empty")
+    if any(not name for name in schema):
+        raise ValueError("schema field names must not be empty")
+    if len(set(schema)) != len(schema):
+        raise ValueError("schema field names must be unique")
+
+
+@PublicEvolving()
+def from_records(
+    data: Sequence[Union[Sequence[Any], Mapping[str, Any]]],
+    schema: Optional[List[str]] = None,
+) -> DataFrame:
+    """
+    Create a DataFrame from row-oriented records.
+
+    For mapping and named tuple records with an explicit ``schema``, every 
record must contain all
+    schema fields; other fields are ignored. When ``schema`` is omitted, the 
keys or fields from
+    the first record are used as the schema and every record must have exactly 
those fields.
+
+    For other sequence records, every record must have the same number of 
values. A ``schema`` is
+    required to provide the field names.
+
+    Field types are inferred from the record values.
+
+    :param data: Non-empty sequence of mapping or sequence records.
+    :param schema: Optional non-empty list of field names.
+    :return: A DataFrame containing the records.
+    :raises TypeError: If a record or schema has an invalid type.
+    :raises ValueError: If data or schema is empty, schema field names are 
invalid, a required
+        schema is omitted, a required field is absent, inferred record fields 
differ, or record
+        widths differ.
+
+    Example::
+
+        >>> import pyflink.dataframe as pf
+        >>> users = pf.from_records([
+        ...     {"id": 1, "name": "Alice"},
+        ...     {"id": 2, "name": "Bob"},
+        ... ])
+        >>> users = pf.from_records(
+        ...     [(1, "Alice"), (2, "Bob")], schema=["id", "name"]
+        ... )
+        >>> from typing import NamedTuple
+        >>> class User(NamedTuple):
+        ...     id: int
+        ...     name: str
+        >>> users = pf.from_records([User(1, "Alice"), User(2, "Bob")])
+        >>> selected_users = pf.from_records(
+        ...     [User(1, "Alice")], schema=["name", "id"]
+        ... )
+
+    .. versionadded:: 2.4.0
+    """
+    if not isinstance(data, Sequence) or isinstance(data, 
_SCALAR_SEQUENCE_TYPES):
+        raise TypeError(
+            "data must be a sequence of records, such as a list or tuple"
+        )
+    if not data:
+        raise ValueError("data must not be empty")
+
+    first_record = data[0]
+    try:
+        expected_record_type = _RecordType.from_record(first_record)
+    except TypeError as error:
+        raise TypeError("invalid record at index 0") from error
+
+    if expected_record_type is _RecordType.SEQUENCE:
+        if schema is None:
+            raise ValueError("schema is required for sequence records")
+        require_exact_fields = True
+    elif schema is None:
+        schema = list(expected_record_type.field_names(first_record))
+        require_exact_fields = True
+    else:
+        require_exact_fields = False
+
+    _validate_schema(schema)
+    rows: List[Sequence[Any]] = []
+    for index, record in enumerate(data):
+        try:
+            if index > 0:
+                expected_record_type.validate(record)
+            row = expected_record_type.normalize_record(
+                record, schema, require_exact_fields
+            )
+        except TypeError as error:
+            raise TypeError(f"invalid record at index {index}") from error
+        except ValueError as error:
+            raise ValueError(f"invalid record at index {index}") from error
+        rows.append(row)
+
+    return DataFrame(
+        get_or_create_table_environment().from_elements(rows, schema)
+    )
+
+
+@PublicEvolving()
+def from_dict(
+    data: Mapping[str, Sequence[Any]], schema: Optional[List[str]] = None
+) -> DataFrame:
+    """
+    Create a DataFrame from a column-oriented dictionary.
+
+    All selected columns must contain the same non-zero number of values. 
``schema`` can select a
+    subset of columns and controls their order. If omitted, dictionary 
insertion order is used.
+
+    :param data: Non-empty mapping of column names to value sequences.
+    :param schema: Optional non-empty list of selected column names.
+    :return: A DataFrame containing the selected columns.
+    :raises TypeError: If ``data`` is not a mapping, or the selected schema or 
a selected column
+        value has an invalid type.
+    :raises ValueError: If the input is empty, schema field names are invalid, 
selected column
+        lengths differ, or a selected column is missing.
+
+    Example::
+
+        >>> import pyflink.dataframe as pf
+        >>> users = pf.from_dict(
+        ...     {"name": ["Alice", "Bob"], "id": [1, 2]},
+        ...     schema=["id", "name"],
+        ... )
+
+    .. versionadded:: 2.4.0
+    """
+    if not isinstance(data, Mapping):
+        raise TypeError("data must be a mapping")
+    if not data:
+        raise ValueError("data must not be empty")
+    if schema is None:
+        schema = list(data.keys())
+    _validate_schema(schema)
+    for name in schema:
+        if name not in data:
+            raise ValueError(f"column {name!r} is not present in data")
+        values = data[name]
+        if not isinstance(values, Sequence) or isinstance(
+            values, _SCALAR_SEQUENCE_TYPES
+        ):
+            raise TypeError(
+                f"column {name!r} values must be a sequence, "
+                "such as a list or tuple"
+            )
+    lengths = {name: len(data[name]) for name in schema}
+    if len(set(lengths.values())) != 1:
+        raise ValueError("columns must have equal lengths")
+    row_count = next(iter(lengths.values()))
+    if row_count == 0:
+        raise ValueError("data must contain at least one row")
+    rows = [
+        tuple(data[name][row_index] for name in schema)
+        for row_index in range(row_count)
+    ]
+    return DataFrame(
+        get_or_create_table_environment().from_elements(rows, schema)
+    )
diff --git a/flink-python/pyflink/dataframe/dataframe.py 
b/flink-python/pyflink/dataframe/dataframe.py
new file mode 100644
index 00000000000..66c2020ee4d
--- /dev/null
+++ b/flink-python/pyflink/dataframe/dataframe.py
@@ -0,0 +1,350 @@
+################################################################################
+#  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.
+################################################################################
+
+from typing import Any, Callable, List, Optional, Tuple, Union, overload
+
+from pyflink.common import Row
+from pyflink.dataframe.datatype import DataType
+from pyflink.table.expression import Expression
+from pyflink.table.expressions import (
+    and_,
+    call_sql,
+    col as table_col,
+    lit as table_lit,
+)
+from pyflink.table.table import Table
+from pyflink.table.types import DataTypes as TableDataTypes
+from pyflink.util.api_stability_decorators import PublicEvolving
+
+__all__ = ["DataFrame", "col", "lit"]
+
+
+@PublicEvolving()
+def col(name: str) -> Expression:
+    """
+    Create a column reference expression.
+
+    :param name: Name of the referenced column.
+    :return: An expression referencing the column.
+
+    Example::
+
+        >>> import pyflink.dataframe as pf
+        >>> df = pf.from_records([{"id": 1, "name": "Alice"}])
+        >>> result = df.select(pf.col("name"))
+
+    .. versionadded:: 2.4.0
+    """
+    return table_col(name)
+
+
+@PublicEvolving()
+def lit(value: Any, data_type: Optional[DataType] = None) -> Expression:
+    """
+    Create a literal expression.
+
+    The data type is inferred from ``value`` when ``data_type`` is omitted. 
Otherwise, the
+    declared data type is applied during literal construction.
+
+    :param value: Literal value.
+    :param data_type: Optional data type for the literal.
+    :return: A literal expression.
+    :raises TypeError: If ``data_type`` is not a :class:`DataType`.
+
+    Example::
+
+        >>> import pyflink.dataframe as pf
+        >>> df = pf.from_records([{"id": 1}])
+        >>> result = df.select("id", status=pf.lit("active"))
+
+    .. versionadded:: 2.4.0
+    """
+    if data_type is None:
+        return table_lit(value)
+    if not isinstance(data_type, DataType):
+        raise TypeError("data_type must be a pyflink.dataframe.DataType")
+    table_data_type = data_type._to_table_data_type()
+    if value is None:
+        return table_lit(value, table_data_type)
+    if (
+        table_data_type.nullable() == TableDataTypes.BIGINT()
+        and isinstance(value, int)
+        and not isinstance(value, bool)
+        and -(1 << 31) <= value < (1 << 31)
+    ):
+        # Py4J sends Python integers in this range as java.lang.Integer, but a 
typed BIGINT
+        # literal requires java.lang.Long. Match BIGINT independently of its 
nullability, then
+        # cast a typed INT literal to the originally declared BIGINT type.
+        return table_lit(value, 
TableDataTypes.INT().not_null()).cast(table_data_type)
+    return table_lit(value, table_data_type.not_null())
+
+
+@PublicEvolving()
+class DataFrame:
+    """
+    A modern DataFrame API for PyFlink.
+
+    DataFrame provides a Pythonic interface for data transformations. It 
supports fluent chaining
+    of operations and provides a familiar DataFrame-style API.
+
+    Example::
+
+        >>> import pyflink.dataframe as pf
+        >>> df = pf.from_dict({"id": [1, 2], "name": ["a", "b"]})
+        >>> result = df.select("id", "name") \\
+        ...              .with_column("id_doubled", pf.col("id") * 2) \\
+        ...              .filter(pf.col("id") > 0)
+
+    .. versionadded:: 2.4.0
+    """
+
+    def __init__(self, table: Table):
+        self._table = table
+
+    @PublicEvolving()
+    def filter(
+        self,
+        *predicates: Union[
+            Expression, str, Callable[["DataFrame"], Expression]
+        ],
+        **constraints: Any,
+    ) -> "DataFrame":
+        """
+        Keep rows that satisfy every predicate and equality constraint.
+
+        Predicates may be boolean expressions, SQL expression strings, or 
callables that receive
+        this DataFrame and return a boolean expression. A constraint value of 
``None`` selects
+        rows where the corresponding column is null.
+
+        :param predicates: Conditions used to test each row.
+        :param constraints: Values keyed by the column names that must equal 
them.
+        :return: A new filtered DataFrame.
+        :raises TypeError: If a predicate has an unsupported type or callable 
result.
+        :raises ValueError: If no predicates or constraints are provided.
+
+        Example::
+
+            >>> import pyflink.dataframe as pf
+            >>> df = pf.from_records([
+            ...     {"name": "Alice", "age": 30, "status": "active"},
+            ...     {"name": "Bob", "age": 17, "status": "active"},
+            ... ])
+            >>> adults = df.filter(pf.col("age") >= 18, status="active")
+            >>> adults = df.filter(lambda current: current["age"] >= 18)
+            >>> missing_status = df.filter(status=None)
+
+        .. versionadded:: 2.4.0
+        """
+        if not predicates and not constraints:
+            raise ValueError(
+                "filter() requires at least one predicate or equality 
constraint"
+            )
+
+        conditions: List[Expression] = []
+        for predicate in predicates:
+            if isinstance(predicate, str):
+                conditions.append(call_sql(predicate))
+            elif isinstance(predicate, Expression):
+                conditions.append(predicate)
+            elif callable(predicate) and not isinstance(predicate, type):
+                condition = predicate(self)
+                if not isinstance(condition, Expression):
+                    raise TypeError(
+                        "filter() callable predicates must return an 
Expression"
+                    )
+                conditions.append(condition)
+            else:
+                raise TypeError(
+                    "predicate must be an Expression, SQL string, or callable"
+                )
+        for name, value in constraints.items():
+            column = table_col(name)
+            conditions.append(column.is_null if value is None else column == 
table_lit(value))
+
+        condition = conditions[0] if len(conditions) == 1 else 
and_(*conditions)
+        return DataFrame(self._table.filter(condition))
+
+    @PublicEvolving()
+    def with_column(
+        self,
+        name: str,
+        expr: Union[Expression, Callable[["DataFrame"], Expression]],
+    ) -> "DataFrame":
+        """
+        Add a column, or replace an existing column with the same name.
+
+        ``expr`` may be an expression or a callable that receives this 
DataFrame and returns an
+        expression.
+
+        :param name: Name of the added or replaced column.
+        :param expr: Expression or callable used to compute the column value.
+        :return: A new DataFrame with the requested column.
+        :raises TypeError: If ``name`` is not a string or ``expr`` does not 
produce an expression.
+
+        Example::
+
+            >>> import pyflink.dataframe as pf
+            >>> df = pf.from_records([{"left": 1, "right": 2}])
+            >>> result = df.with_column(
+            ...     "total", lambda current: current["left"] + current["right"]
+            ... )
+
+        .. versionadded:: 2.4.0
+        """
+        if not isinstance(name, str):
+            raise TypeError("name must be a string")
+        if isinstance(expr, Expression):
+            expression = expr
+        elif callable(expr) and not isinstance(expr, type):
+            expression = expr(self)
+        else:
+            raise TypeError("expr must be an Expression")
+        if not isinstance(expression, Expression):
+            raise TypeError("expr must be an Expression")
+        return 
DataFrame(self._table.add_or_replace_columns(expression.alias(name)))
+
+    @PublicEvolving()
+    def select(
+        self,
+        *columns: Union[
+            str,
+            Expression,
+            List[Union[str, Expression]],
+            Tuple[Union[str, Expression], ...],
+        ],
+        **projections: Expression,
+    ) -> "DataFrame":
+        """
+        Select columns and compute named projections.
+
+        Column names and expressions are included in the supplied order. A 
list or tuple may be
+        used to group column names and expressions. Named projections are 
appended after the
+        positional columns.
+
+        :param columns: Column names and expressions to select.
+        :param projections: Expressions keyed by their result column names.
+        :return: A new DataFrame containing the selected columns and 
projections.
+        :raises TypeError: If a column or projection is not a supported value.
+
+        Example::
+
+            >>> import pyflink.dataframe as pf
+            >>> df = pf.from_records([{"id": 1, "name": "Alice"}])
+            >>> result = df.select(
+            ...     ("name", "id"), doubled=pf.col("id") * 2
+            ... )
+
+        .. versionadded:: 2.4.0
+        """
+        expressions: List[Expression] = []
+        for column in columns:
+            values = column if isinstance(column, (list, tuple)) else [column]
+            for value in values:
+                if isinstance(value, str):
+                    expressions.append(table_col(value))
+                elif isinstance(value, Expression):
+                    expressions.append(value)
+                else:
+                    raise TypeError(
+                        "columns must be strings, expressions, or lists or 
tuples of them"
+                    )
+
+        for name, projection in projections.items():
+            if not isinstance(projection, Expression):
+                raise TypeError("projections must be expressions")
+            expressions.append(projection.alias(name))
+
+        return DataFrame(self._table.select(*expressions))
+
+    @overload
+    def __getitem__(self, key: str) -> Expression:
+        ...
+
+    @overload
+    def __getitem__(
+        self, key: List[Union[str, Expression]]
+    ) -> "DataFrame":
+        ...
+
+    @overload
+    def __getitem__(
+        self, key: Tuple[Union[str, Expression], ...]
+    ) -> "DataFrame":
+        ...
+
+    @overload
+    def __getitem__(self, key: Expression) -> "DataFrame":
+        ...
+
+    @PublicEvolving()
+    def __getitem__(
+        self,
+        key: Union[
+            str,
+            List[Union[str, Expression]],
+            Tuple[Union[str, Expression], ...],
+            Expression,
+        ],
+    ) -> Union["DataFrame", Expression]:
+        """
+        Select a column, select multiple columns, or filter rows.
+
+        A string returns its column expression, a list or tuple returns a 
DataFrame containing the
+        listed columns, and a boolean expression returns a filtered DataFrame.
+
+        :param key: Column name, list or tuple of columns, or boolean 
expression.
+        :return: A column expression or a new DataFrame.
+        :raises TypeError: If ``key`` has an unsupported type.
+
+        Example::
+
+            >>> import pyflink.dataframe as pf
+            >>> df = pf.from_records([{"id": 1, "name": "Alice"}])
+            >>> identifier = df["id"]
+            >>> selected = df[("id", "name")]
+            >>> filtered = df[df["id"] > 0]
+
+        .. versionadded:: 2.4.0
+        """
+        if isinstance(key, str):
+            return table_col(key)
+        if isinstance(key, (list, tuple)):
+            return self.select(key)
+        if isinstance(key, Expression):
+            return self.filter(key)
+        raise TypeError("key must be a string, list, tuple, or Expression")
+
+    @PublicEvolving()
+    def collect(self) -> List[Row]:
+        """
+        Execute this DataFrame and return all rows.
+
+        The result iterator is always closed before this method returns or 
propagates an error.
+
+        :return: All result rows in collection order.
+
+        Example::
+
+            >>> import pyflink.dataframe as pf
+            >>> df = pf.from_records([{"id": 1}, {"id": 2}])
+            >>> rows = df.collect()
+
+        .. versionadded:: 2.4.0
+        """
+        with self._table.execute().collect() as rows:
+            return list(rows)
diff --git a/flink-python/pyflink/dataframe/datatype.py 
b/flink-python/pyflink/dataframe/datatype.py
new file mode 100644
index 00000000000..c22f4ba6d68
--- /dev/null
+++ b/flink-python/pyflink/dataframe/datatype.py
@@ -0,0 +1,85 @@
+################################################################################
+#  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.
+################################################################################
+
+from pyflink.table.types import DataType as TableDataType, DataTypes
+from pyflink.util.api_stability_decorators import PublicEvolving
+
+__all__ = ["DataType"]
+
+
+@PublicEvolving()
+class DataType:
+    """
+    Describes the logical data type of a value in the DataFrame API.
+
+    Data types declare the types of values used by DataFrame expressions and 
operations.
+
+    Example::
+
+        >>> import pyflink.dataframe as pf
+        >>> integer_type = pf.DataType.int64()
+        >>> string_type = pf.DataType.string()
+
+    .. versionadded:: 2.4.0
+    """
+
+    def __init__(self, table_data_type: TableDataType):
+        self._table_data_type = table_data_type
+
+    @PublicEvolving()
+    def __eq__(self, other: object) -> bool:
+        if not isinstance(other, DataType):
+            return False
+        return self._table_data_type == other._table_data_type
+
+    @PublicEvolving()
+    def __hash__(self) -> int:
+        return hash(str(self._table_data_type))
+
+    @classmethod
+    @PublicEvolving()
+    def int64(cls) -> "DataType":
+        """
+        Create a 64-bit integer type.
+
+        Example::
+
+            >>> import pyflink.dataframe as pf
+            >>> expression = pf.lit(42, pf.DataType.int64())
+
+        .. versionadded:: 2.4.0
+        """
+        return cls(DataTypes.BIGINT())
+
+    @classmethod
+    @PublicEvolving()
+    def string(cls) -> "DataType":
+        """
+        Create a variable-length string type.
+
+        Example::
+
+            >>> import pyflink.dataframe as pf
+            >>> expression = pf.lit("Alice", pf.DataType.string())
+
+        .. versionadded:: 2.4.0
+        """
+        return cls(DataTypes.STRING())
+
+    def _to_table_data_type(self) -> TableDataType:
+        return self._table_data_type
diff --git a/flink-python/pyflink/dataframe/tests/__init__.py 
b/flink-python/pyflink/dataframe/tests/__init__.py
new file mode 100644
index 00000000000..65b48d4d79b
--- /dev/null
+++ b/flink-python/pyflink/dataframe/tests/__init__.py
@@ -0,0 +1,17 @@
+################################################################################
+#  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.
+################################################################################
diff --git a/flink-python/pyflink/dataframe/tests/test_context.py 
b/flink-python/pyflink/dataframe/tests/test_context.py
new file mode 100644
index 00000000000..21f1f28ef56
--- /dev/null
+++ b/flink-python/pyflink/dataframe/tests/test_context.py
@@ -0,0 +1,93 @@
+################################################################################
+#  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 unittest
+from typing import get_type_hints, Optional
+
+import pyflink.dataframe as pf
+from pyflink.table import EnvironmentSettings, TableEnvironment
+from pyflink.testing.test_case_utils import PyFlinkUTTestCase
+
+
+class TableEnvironmentContextValidationTests(unittest.TestCase):
+    def test_public_type_hints_are_resolvable(self):
+        self.assertEqual(
+            get_type_hints(pf.set_table_environment),
+            {
+                "t_env": Optional[TableEnvironment],
+                "return": type(None),
+            },
+        )
+        self.assertEqual(
+            get_type_hints(pf.get_table_environment),
+            {"return": Optional[TableEnvironment]},
+        )
+        self.assertEqual(
+            get_type_hints(pf.get_or_create_table_environment),
+            {"return": TableEnvironment},
+        )
+
+    def test_set_environment_rejects_invalid_type_without_changing_state(self):
+        previous_environment = pf.get_table_environment()
+        self.addCleanup(pf.set_table_environment, previous_environment)
+
+        with self.assertRaisesRegex(
+            TypeError, "t_env must be a TableEnvironment or None"
+        ):
+            pf.set_table_environment(object())
+
+        self.assertIs(pf.get_table_environment(), previous_environment)
+
+
+class TableEnvironmentContextTests(PyFlinkUTTestCase):
+    def setUp(self):
+        super().setUp()
+        previous_environment = pf.get_table_environment()
+        self.addCleanup(pf.set_table_environment, previous_environment)
+
+    def test_set_environment_makes_it_retrievable(self):
+        pf.set_table_environment(self.t_env)
+
+        self.assertIs(pf.get_table_environment(), self.t_env)
+
+    def test_set_batch_table_environment_makes_it_retrievable(self):
+        batch_environment = TableEnvironment.create(
+            EnvironmentSettings.in_batch_mode()
+        )
+
+        pf.set_table_environment(batch_environment)
+
+        self.assertIs(pf.get_table_environment(), batch_environment)
+
+    def test_set_none_clears_the_environment(self):
+        pf.set_table_environment(self.t_env)
+
+        pf.set_table_environment(None)
+
+        self.assertIsNone(pf.get_table_environment())
+
+    def test_created_environment_is_retrievable(self):
+        pf.set_table_environment(None)
+
+        created_environment = pf.get_or_create_table_environment()
+
+        self.assertIs(created_environment, pf.get_table_environment())
+
+
+if __name__ == "__main__":
+    unittest.main()
diff --git a/flink-python/pyflink/dataframe/tests/test_convert.py 
b/flink-python/pyflink/dataframe/tests/test_convert.py
new file mode 100644
index 00000000000..f08ede67ab7
--- /dev/null
+++ b/flink-python/pyflink/dataframe/tests/test_convert.py
@@ -0,0 +1,228 @@
+################################################################################
+#  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 unittest
+from typing import NamedTuple
+
+import pyflink.dataframe as pf
+
+
+class _Point(NamedTuple):
+    x: int
+    y: str
+
+
+class _OtherPoint(NamedTuple):
+    x: int
+    z: str
+
+
+class FromRecordsTests(unittest.TestCase):
+    def test_rejects_scalar_sequence_data(self):
+        for data in ["ab", b"ab", bytearray(b"ab"), memoryview(b"ab")]:
+            with self.subTest(data_type=type(data)):
+                with self.assertRaisesRegex(
+                    TypeError,
+                    "data must be a sequence of records, such as a list or 
tuple",
+                ):
+                    pf.from_records(data, schema=["value"])
+
+    def test_rejects_empty_data(self):
+        with self.assertRaisesRegex(ValueError, "data must not be empty"):
+            pf.from_records([], schema=["id"])
+
+    def test_rejects_empty_schema(self):
+        with self.assertRaisesRegex(ValueError, "schema must not be empty"):
+            pf.from_records([(1,)], schema=[])
+
+    def test_rejects_schema_that_is_not_a_list_of_strings(self):
+        for schema in [0, False, "", (), ("id",), [1]]:
+            with self.subTest(schema=schema):
+                with self.assertRaisesRegex(
+                    TypeError, "schema must be a list of strings"
+                ):
+                    pf.from_records([(1,)], schema=schema)
+
+    def test_rejects_empty_schema_field_name(self):
+        with self.assertRaisesRegex(ValueError, "schema field names must not 
be empty"):
+            pf.from_records([(1,)], schema=[""])
+
+    def test_rejects_duplicate_schema_field_names(self):
+        with self.assertRaisesRegex(ValueError, "schema field names must be 
unique"):
+            pf.from_records([(1, 2)], schema=["id", "id"])
+
+    def test_requires_schema_for_sequence_records(self):
+        with self.assertRaisesRegex(ValueError, "schema is required for 
sequence records"):
+            pf.from_records([(1,)])
+
+    def test_rejects_unsupported_record_type(self):
+        with self.assertRaises(TypeError) as error:
+            pf.from_records([1], schema=["id"])
+        self.assertEqual(str(error.exception), "invalid record at index 0")
+        self.assertEqual(
+            str(error.exception.__cause__),
+            "record must be a mapping or a sequence of values, "
+            "such as a list or tuple",
+        )
+
+    def test_rejects_scalar_sequence_records(self):
+        for value in ["ab", b"ab", bytearray(b"ab"), memoryview(b"ab")]:
+            for index, records, cause in [
+                (
+                    0,
+                    [value],
+                    "record must be a mapping or a sequence of values, "
+                    "such as a list or tuple",
+                ),
+                (1, [(1, 2), value], "record must be a sequence"),
+            ]:
+                with self.subTest(value_type=type(value), index=index):
+                    with self.assertRaises(TypeError) as error:
+                        pf.from_records(records, schema=["left", "right"])
+                    self.assertEqual(
+                        str(error.exception), f"invalid record at index 
{index}"
+                    )
+                    self.assertEqual(str(error.exception.__cause__), cause)
+
+    def test_rejects_record_with_wrong_arity(self):
+        with self.assertRaises(ValueError) as error:
+            pf.from_records([(1, "Alice"), (2,)], schema=["id", "name"])
+        self.assertEqual(str(error.exception), "invalid record at index 1")
+        self.assertEqual(
+            str(error.exception.__cause__),
+            "record has 1 values but schema has 2 fields",
+        )
+
+    def test_rejects_mapping_records_with_different_keys(self):
+        records_with_different_keys = [
+            (
+                [{"a": 1}, {"a": 2, "b": 3}],
+                "record has fields not present in schema: ['b']",
+            ),
+            (
+                [{"a": 1, "b": 2}, {"a": 3}],
+                "record is missing schema field 'b'",
+            ),
+        ]
+        for records, cause in records_with_different_keys:
+            with self.subTest(records=records):
+                with self.assertRaises(ValueError) as error:
+                    pf.from_records(records)
+                self.assertEqual(str(error.exception), "invalid record at 
index 1")
+                self.assertEqual(str(error.exception.__cause__), cause)
+
+    def test_rejects_mixed_mapping_and_named_tuple_records_with_index(self):
+        invalid_records = [
+            (
+                [{"id": 1}, (2,)],
+                ["id"],
+                "record must be a mapping",
+            ),
+            (
+                [_Point(1, "a"), {"x": 2, "y": "b"}],
+                ["x", "y"],
+                "record must be a named tuple",
+            ),
+        ]
+        for records, schema, cause in invalid_records:
+            with self.subTest(records=records):
+                with self.assertRaises(TypeError) as error:
+                    pf.from_records(records, schema=schema)
+                self.assertEqual(str(error.exception), "invalid record at 
index 1")
+                self.assertEqual(str(error.exception.__cause__), cause)
+
+    def test_rejects_schema_that_renames_named_tuple_fields(self):
+        with self.assertRaises(ValueError) as error:
+            pf.from_records([_Point(1, "a")], schema=["a", "b"])
+        self.assertEqual(str(error.exception), "invalid record at index 0")
+        self.assertEqual(
+            str(error.exception.__cause__), "record is missing schema field 
'a'"
+        )
+
+    def test_rejects_different_inferred_named_tuple_fields(self):
+        with self.assertRaises(ValueError) as error:
+            pf.from_records([_Point(1, "a"), _OtherPoint(2, "b")])
+        self.assertEqual(str(error.exception), "invalid record at index 1")
+        self.assertEqual(
+            str(error.exception.__cause__),
+            "record is missing schema field 'y'",
+        )
+
+    def test_rejects_schema_field_missing_from_mapping_records(self):
+        with self.assertRaises(ValueError) as error:
+            pf.from_records(
+                [{"id": 1, "name": "Alice"}, {"id": 2}],
+                schema=["id", "name"],
+            )
+        self.assertEqual(str(error.exception), "invalid record at index 1")
+        self.assertEqual(
+            str(error.exception.__cause__), "record is missing schema field 
'name'"
+        )
+
+
+class FromDictTests(unittest.TestCase):
+    def test_rejects_data_that_is_not_a_mapping(self):
+        for data in [[], [("id", [1])]]:
+            with self.subTest(data=data):
+                with self.assertRaisesRegex(TypeError, "data must be a 
mapping"):
+                    pf.from_dict(data)
+
+    def test_rejects_scalar_sequence_column_values(self):
+        for values in ["ab", b"ab", bytearray(b"ab"), memoryview(b"ab")]:
+            with self.subTest(value_type=type(values)):
+                with self.assertRaisesRegex(
+                    TypeError,
+                    "column 'value' values must be a sequence, "
+                    "such as a list or tuple",
+                ):
+                    pf.from_dict({"value": values})
+
+    def test_rejects_empty_data(self):
+        with self.assertRaisesRegex(ValueError, "data must not be empty"):
+            pf.from_dict({})
+
+    def test_rejects_zero_rows(self):
+        with self.assertRaisesRegex(ValueError, "data must contain at least 
one row"):
+            pf.from_dict({"id": []})
+
+    def test_rejects_columns_with_different_lengths(self):
+        with self.assertRaisesRegex(ValueError, "columns must have equal 
lengths"):
+            pf.from_dict({"id": [1, 2], "name": ["Alice"]})
+
+    def test_rejects_schema_column_missing_from_data(self):
+        with self.assertRaisesRegex(ValueError, "column 'name' is not present 
in data"):
+            pf.from_dict({"id": [1]}, schema=["id", "name"])
+
+    def test_rejects_schema_that_is_not_a_list_of_strings(self):
+        for schema in [0, False, "", (), ("id",), [1]]:
+            with self.subTest(schema=schema):
+                with self.assertRaisesRegex(
+                    TypeError, "schema must be a list of strings"
+                ):
+                    pf.from_dict({"id": [1]}, schema=schema)
+
+    def test_rejects_empty_schema_field_name(self):
+        with self.assertRaisesRegex(ValueError, "schema field names must not 
be empty"):
+            pf.from_dict({"id": [1]}, schema=[""])
+
+    def test_rejects_duplicate_schema_field_names(self):
+        with self.assertRaisesRegex(ValueError, "schema field names must be 
unique"):
+            pf.from_dict({"id": [1]}, schema=["id", "id"])
+
+if __name__ == "__main__":
+    unittest.main()
diff --git a/flink-python/pyflink/dataframe/tests/test_dataframe.py 
b/flink-python/pyflink/dataframe/tests/test_dataframe.py
new file mode 100644
index 00000000000..98714e9fbb6
--- /dev/null
+++ b/flink-python/pyflink/dataframe/tests/test_dataframe.py
@@ -0,0 +1,575 @@
+################################################################################
+#  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 unittest
+from typing import NamedTuple
+
+import pyflink.dataframe as pf
+from py4j.protocol import Py4JJavaError
+from pyflink.common import Row
+from pyflink.table import (
+    DataTypes as TableDataTypes,
+    EnvironmentSettings,
+    TableEnvironment,
+)
+from pyflink.table.expression import Expression
+from pyflink.testing.test_case_utils import (
+    PyFlinkDataFrameUTTestCase,
+    PyFlinkITTestCase,
+    PyFlinkStreamDataFrameTestCase,
+)
+
+
+class _Point(NamedTuple):
+    x: int
+    y: str
+
+
+class _CloseableIterator:
+    def __init__(self, values=None, error=None):
+        self._values = iter(values or [])
+        self._error = error
+        self.closed = False
+
+    def __iter__(self):
+        return self
+
+    def __next__(self):
+        if self._error is not None:
+            raise self._error
+        return next(self._values)
+
+    def __enter__(self):
+        return self
+
+    def __exit__(self, exc_type, exc_value, traceback):
+        self.closed = True
+
+
+class _TableResult:
+    def __init__(self, iterator):
+        self._iterator = iterator
+
+    def collect(self):
+        return self._iterator
+
+
+class _Table:
+    def __init__(self, iterator):
+        self._iterator = iterator
+
+    def execute(self):
+        return _TableResult(self._iterator)
+
+
+class DataFrameCollectTests(unittest.TestCase):
+    def test_collect_returns_all_rows_and_closes_iterator(self):
+        iterator = _CloseableIterator([Row(1, "Alice")])
+        dataframe = pf.DataFrame(_Table(iterator))
+
+        self.assertEqual(dataframe.collect(), [Row(1, "Alice")])
+        self.assertTrue(iterator.closed)
+
+    def test_collect_closes_iterator_when_iteration_fails(self):
+        iterator = _CloseableIterator(error=RuntimeError("iteration failed"))
+        dataframe = pf.DataFrame(_Table(iterator))
+
+        with self.assertRaisesRegex(RuntimeError, "iteration failed"):
+            dataframe.collect()
+
+        self.assertTrue(iterator.closed)
+
+
+class DataFrameCreationTests(PyFlinkDataFrameUTTestCase):
+    def test_from_dict_uses_insertion_order_without_schema(self):
+        dataframe = pf.from_dict({"name": ["Alice"], "id": [1]})
+
+        self.assert_dataframe_schema(
+            dataframe,
+            ["name", "id"],
+            [TableDataTypes.STRING(), TableDataTypes.BIGINT()],
+        )
+
+    def test_from_dict_respects_explicit_schema_order_and_subset(self):
+        dataframe = pf.from_dict(
+            {
+                "name": ["Alice"],
+                "ignored": ["x"],
+                "id": [1],
+            },
+            schema=["id", "name"],
+        )
+
+        self.assert_dataframe_schema(
+            dataframe,
+            ["id", "name"],
+            [TableDataTypes.BIGINT(), TableDataTypes.STRING()],
+        )
+
+    def test_from_records_accepts_list_records(self):
+        dataframe = pf.from_records(
+            [[1, "Alice"], [2, "Bob"]],
+            schema=["id", "name"],
+        )
+
+        self.assert_dataframe_schema(
+            dataframe,
+            ["id", "name"],
+            [TableDataTypes.BIGINT(), TableDataTypes.STRING()],
+        )
+
+    def test_from_records_accepts_general_sequence_records(self):
+        dataframe = pf.from_records(
+            [range(2), range(2, 4)],
+            schema=["left", "right"],
+        )
+
+        self.assert_dataframe_schema(
+            dataframe,
+            ["left", "right"],
+            [TableDataTypes.BIGINT(), TableDataTypes.BIGINT()],
+        )
+
+    def test_from_records_infers_mapping_schema(self):
+        dataframe = pf.from_records(
+            [{"name": "Alice", "id": 1}, {"name": "Bob", "id": 2}]
+        )
+
+        self.assert_dataframe_schema(
+            dataframe,
+            ["name", "id"],
+            [TableDataTypes.STRING(), TableDataTypes.BIGINT()],
+        )
+
+    def test_from_records_selects_mapping_fields_with_explicit_schema(self):
+        dataframe = pf.from_records(
+            [
+                {"name": "Alice", "id": 1, "ignored": "x"},
+                {"name": "Bob", "id": 2, "ignored": "y"},
+            ],
+            schema=["id", "name"],
+        )
+
+        self.assert_dataframe_schema(
+            dataframe,
+            ["id", "name"],
+            [TableDataTypes.BIGINT(), TableDataTypes.STRING()],
+        )
+
+    def test_from_records_infers_named_tuple_schema(self):
+        dataframe = pf.from_records([_Point(1, "a"), _Point(2, "b")])
+
+        self.assert_dataframe_schema(
+            dataframe,
+            ["x", "y"],
+            [TableDataTypes.BIGINT(), TableDataTypes.STRING()],
+        )
+
+    def 
test_from_records_selects_named_tuple_fields_with_explicit_schema(self):
+        dataframe = pf.from_records(
+            [_Point(1, "a"), _Point(2, "b")],
+            schema=["y", "x"],
+        )
+
+        self.assert_dataframe_schema(
+            dataframe,
+            ["y", "x"],
+            [TableDataTypes.STRING(), TableDataTypes.BIGINT()],
+        )
+
+
+class DataFrameSelectTests(PyFlinkDataFrameUTTestCase):
+    def setUp(self):
+        super().setUp()
+        self.dataframe = pf.from_records(
+            [(1, "Alice"), (2, "Bob")],
+            schema=["id", "name"],
+        )
+
+    def 
test_select_accepts_names_expressions_lists_and_named_projections(self):
+        result = self.dataframe.select(
+            ["name"],
+            pf.col("id"),
+            doubled=pf.col("id") * 2,
+        )
+
+        self.assert_dataframe_schema(
+            result,
+            ["name", "id", "doubled"],
+            [
+                TableDataTypes.STRING(),
+                TableDataTypes.BIGINT(),
+                TableDataTypes.BIGINT(),
+            ],
+        )
+
+    def test_select_accepts_tuple_column_group(self):
+        result = self.dataframe.select(("name", "id"))
+
+        self.assert_dataframe_schema(
+            result,
+            ["name", "id"],
+            [TableDataTypes.STRING(), TableDataTypes.BIGINT()],
+        )
+
+    def test_select_rejects_non_string_column(self):
+        with self.assertRaisesRegex(TypeError, "columns must be strings"):
+            self.dataframe.select(42)
+
+    def test_select_rejects_non_expression_projection(self):
+        with self.assertRaisesRegex(TypeError, "projections must be 
expressions"):
+            self.dataframe.select(answer=42)
+
+
+class DataFrameWithColumnTests(PyFlinkDataFrameUTTestCase):
+    def setUp(self):
+        super().setUp()
+        self.dataframe = pf.from_records(
+            [(1, "Alice", 30)],
+            schema=["id", "name", "age"],
+        )
+
+    def test_with_column_adds_callable_result(self):
+        result = self.dataframe.with_column(
+            "age_next_year",
+            lambda current: current["age"] + 1,
+        )
+
+        self.assert_dataframe_schema(
+            result,
+            ["id", "name", "age", "age_next_year"],
+            [
+                TableDataTypes.BIGINT(),
+                TableDataTypes.STRING(),
+                TableDataTypes.BIGINT(),
+                TableDataTypes.BIGINT(),
+            ],
+        )
+
+    def test_with_column_replaces_existing_column(self):
+        result = self.dataframe.with_column("age", pf.col("age") + 1)
+
+        self.assert_dataframe_schema(
+            result,
+            ["id", "name", "age"],
+            [
+                TableDataTypes.BIGINT(),
+                TableDataTypes.STRING(),
+                TableDataTypes.BIGINT(),
+            ],
+        )
+
+    def test_with_column_rejects_non_expression(self):
+        with self.assertRaisesRegex(TypeError, "expr must be an Expression"):
+            self.dataframe.with_column("answer", 42)
+
+    def test_with_column_rejects_callable_returning_non_expression(self):
+        with self.assertRaisesRegex(TypeError, "expr must be an Expression"):
+            self.dataframe.with_column("answer", lambda df: 42)
+
+    def test_with_column_rejects_expression_class(self):
+        with self.assertRaisesRegex(TypeError, "expr must be an Expression"):
+            self.dataframe.with_column("answer", Expression)
+
+    def test_with_column_rejects_non_string_name(self):
+        with self.assertRaisesRegex(TypeError, "name must be a string"):
+            self.dataframe.with_column(42, object())
+
+
+class DataFrameFilterTests(PyFlinkDataFrameUTTestCase):
+    def setUp(self):
+        super().setUp()
+        self.dataframe = pf.from_records(
+            [(1, 0.9, "NYC", None), (2, 0.95, "SF", "Paris")],
+            schema=["id", "score", "city", "destination"],
+        )
+
+    def assert_filter_schema(self, dataframe):
+        self.assert_dataframe_schema(
+            dataframe,
+            ["id", "score", "city", "destination"],
+            [
+                TableDataTypes.BIGINT(),
+                TableDataTypes.DOUBLE(),
+                TableDataTypes.STRING(),
+                TableDataTypes.STRING(),
+            ],
+        )
+
+    def test_filter_accepts_expression(self):
+        self.assert_filter_schema(self.dataframe.filter(pf.col("id") > 0))
+
+    def test_filter_accepts_multiple_predicates_and_constraints(self):
+        result = self.dataframe.filter(
+            pf.col("id") > 0,
+            pf.col("score") >= 0.8,
+            city="NYC",
+        )
+
+        self.assert_filter_schema(result)
+
+    def test_filter_accepts_none_constraint(self):
+        self.assert_filter_schema(self.dataframe.filter(destination=None))
+
+    def test_filter_accepts_sql_string_predicate(self):
+        self.assert_filter_schema(self.dataframe.filter("id > 0"))
+
+    def test_filter_accepts_callable_predicate(self):
+        self.assert_filter_schema(
+            self.dataframe.filter(lambda current: current["id"] > 0)
+        )
+
+    def test_filter_rejects_non_expression(self):
+        with self.assertRaisesRegex(TypeError, "predicate must be an 
Expression"):
+            self.dataframe.filter(True)
+
+    def test_filter_requires_a_condition(self):
+        with self.assertRaisesRegex(ValueError, "requires at least one 
predicate"):
+            self.dataframe.filter()
+
+    def test_filter_rejects_callable_returning_non_expression(self):
+        with self.assertRaisesRegex(
+            TypeError, "callable predicates must return an Expression"
+        ):
+            self.dataframe.filter(lambda df: True)
+
+    def test_filter_rejects_expression_class(self):
+        with self.assertRaisesRegex(TypeError, "predicate must be an 
Expression"):
+            self.dataframe.filter(Expression)
+
+
+class DataFrameGetItemTests(PyFlinkDataFrameUTTestCase):
+    def setUp(self):
+        super().setUp()
+        self.dataframe = pf.from_records(
+            [(1, "Alice"), (2, "Bob")],
+            schema=["id", "name"],
+        )
+
+    def test_getitem_returns_expression_for_column_name(self):
+        self.assertIsInstance(self.dataframe["id"], Expression)
+
+    def test_getitem_selects_list_projection(self):
+        result = self.dataframe[["name", "id"]]
+
+        self.assert_dataframe_schema(
+            result,
+            ["name", "id"],
+            [TableDataTypes.STRING(), TableDataTypes.BIGINT()],
+        )
+
+    def test_getitem_selects_tuple_projection(self):
+        result = self.dataframe[("name", "id")]
+
+        self.assert_dataframe_schema(
+            result,
+            ["name", "id"],
+            [TableDataTypes.STRING(), TableDataTypes.BIGINT()],
+        )
+
+    def test_getitem_filters_with_expression(self):
+        result = self.dataframe[self.dataframe["id"] > 0]
+
+        self.assert_dataframe_schema(
+            result,
+            ["id", "name"],
+            [TableDataTypes.BIGINT(), TableDataTypes.STRING()],
+        )
+
+    def test_getitem_rejects_unsupported_key(self):
+        with self.assertRaisesRegex(TypeError, "key must be a string, list"):
+            self.dataframe[42]
+
+
+class DataFrameLiteralTests(PyFlinkDataFrameUTTestCase):
+    def setUp(self):
+        super().setUp()
+        self.dataframe = pf.from_records([(1,)], schema=["id"])
+
+    def test_lit_supports_inferred_and_explicit_types(self):
+        result = self.dataframe.select(
+            inferred_int=pf.lit(2),
+            inferred_string=pf.lit("x"),
+            explicit_int=pf.lit(3, pf.DataType.int64()),
+            explicit_large_int=pf.lit(1 << 40, pf.DataType.int64()),
+            explicit_string=pf.lit("y", pf.DataType.string()),
+        )
+
+        self.assert_dataframe_schema(
+            result,
+            [
+                "inferred_int",
+                "inferred_string",
+                "explicit_int",
+                "explicit_large_int",
+                "explicit_string",
+            ],
+            [
+                TableDataTypes.INT().not_null(),
+                TableDataTypes.CHAR(1).not_null(),
+                TableDataTypes.BIGINT().not_null(),
+                TableDataTypes.BIGINT().not_null(),
+                TableDataTypes.STRING().not_null(),
+            ],
+        )
+
+    def test_lit_supports_explicitly_typed_nulls(self):
+        result = self.dataframe.select(
+            null_int=pf.lit(None, pf.DataType.int64()),
+            null_string=pf.lit(None, pf.DataType.string()),
+        )
+
+        self.assert_dataframe_schema(
+            result,
+            ["null_int", "null_string"],
+            [TableDataTypes.BIGINT(), TableDataTypes.STRING()],
+        )
+
+    def test_lit_supports_small_int_for_non_nullable_bigint(self):
+        non_nullable_bigint = pf.DataType(TableDataTypes.BIGINT().not_null())
+        result = self.dataframe.select(value=pf.lit(3, non_nullable_bigint))
+
+        self.assert_dataframe_schema(
+            result,
+            ["value"],
+            [TableDataTypes.BIGINT().not_null()],
+        )
+
+    def test_lit_rejects_values_incompatible_with_explicit_type(self):
+        incompatible_values = [
+            (3.14, pf.DataType.int64()),
+            ("abc", pf.DataType.int64()),
+            (42, pf.DataType.string()),
+        ]
+        for value, data_type in incompatible_values:
+            with self.subTest(value=value, data_type=data_type):
+                with self.assertRaises(Py4JJavaError):
+                    pf.lit(value, data_type)
+
+    def test_lit_rejects_non_dataframe_data_type(self):
+        with self.assertRaisesRegex(
+            TypeError, "data_type must be a pyflink.dataframe.DataType"
+        ):
+            pf.lit(1, object())
+
+
+class DataFrameITTests(PyFlinkStreamDataFrameTestCase):
+    def test_from_records(self):
+        dataframe = pf.from_records(
+            [(1, "Alice"), (2, "Bob")],
+            schema=["id", "name"],
+        )
+
+        self.assertEqual(
+            dataframe.collect(),
+            [Row(1, "Alice"), Row(2, "Bob")],
+        )
+
+    def test_basic_functionality(self):
+        df = pf.from_dict(
+            {
+                "name": [
+                    "expression",
+                    "Alice",
+                    "sql",
+                    "constraint",
+                    "null_constraint",
+                    "callable",
+                ],
+                "ignored": ["unused"],
+                "id": [0, 1, 2, 3, 4, 6],
+                "age": [20, 30, 40, 50, 60, 70],
+                "score": [0.95, 0.95, 0.7, 0.95, 0.95, 0.95],
+                "city": ["SF", "SF", "SF", "NYC", "SF", "SF"],
+                "destination": [None, None, None, None, "Paris", None],
+            },
+            schema=["id", "name", "age", "score", "city", "destination"],
+        )
+
+        result = (
+            df[df["id"] > 0]
+            .filter(
+                "score >= 0.9",
+                lambda current: current["id"] < 6,
+                city="SF",
+                destination=None,
+            )
+            .with_column(
+                "age_next_year",
+                lambda current: current["age"] + 1,
+            )
+            .with_column("age", pf.col("age") + 1)
+            .select(
+                "id",
+                "name",
+                "age",
+                age_next_year=pf.col("age_next_year"),
+                inferred_int=pf.lit(2),
+                inferred_string=pf.lit("x"),
+                explicit_int=pf.lit(3, pf.DataType.int64()),
+                explicit_large_int=pf.lit(1 << 40, pf.DataType.int64()),
+                explicit_string=pf.lit("y", pf.DataType.string()),
+                null_int=pf.lit(None, pf.DataType.int64()),
+                null_string=pf.lit(None, pf.DataType.string()),
+                non_nullable_int=pf.lit(
+                    3,
+                    pf.DataType(TableDataTypes.BIGINT().not_null()),
+                ),
+            )[
+                (
+                    "name",
+                    "id",
+                    "age",
+                    "age_next_year",
+                    "inferred_int",
+                    "inferred_string",
+                    "explicit_int",
+                    "explicit_large_int",
+                    "explicit_string",
+                    "null_int",
+                    "null_string",
+                    "non_nullable_int",
+                )
+            ]
+        )
+
+        self.assertEqual(
+            result.collect(),
+            [Row("Alice", 1, 31, 31, 2, "x", 3, 1 << 40, "y", None, None, 3)],
+        )
+
+
+class DataFrameBatchITTests(PyFlinkITTestCase):
+    def setUp(self):
+        previous_environment = pf.get_table_environment()
+        self.addCleanup(pf.set_table_environment, previous_environment)
+        self.t_env = 
TableEnvironment.create(EnvironmentSettings.in_batch_mode())
+
+    def test_from_records_with_batch_table_environment(self):
+        pf.set_table_environment(self.t_env)
+
+        result = pf.from_records(
+            [(1, "Alice"), (2, "Bob")],
+            schema=["id", "name"],
+        ).filter(pf.col("id") > 1)
+
+        self.assertEqual(result.collect(), [Row(2, "Bob")])
+
+
+if __name__ == "__main__":
+    unittest.main()
diff --git a/flink-python/pyflink/dataframe/tests/test_datatype.py 
b/flink-python/pyflink/dataframe/tests/test_datatype.py
new file mode 100644
index 00000000000..756bba371ea
--- /dev/null
+++ b/flink-python/pyflink/dataframe/tests/test_datatype.py
@@ -0,0 +1,65 @@
+################################################################################
+#  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 unittest
+
+import pyflink.dataframe as pf
+from pyflink.table import DataTypes
+from pyflink.util.api_stability_decorators import PublicEvolving
+
+
+class DataTypeTests(unittest.TestCase):
+    def test_public_factory_surface(self):
+        public_methods = {
+            name for name in dir(pf.DataType) if not name.startswith("_")
+        }
+
+        self.assertEqual(public_methods, {"int64", "string"})
+
+    def test_int64_maps_to_table_bigint(self):
+        self.assertEqual(pf.DataType.int64()._to_table_data_type(), 
DataTypes.BIGINT())
+
+    def test_string_maps_to_table_string(self):
+        self.assertEqual(pf.DataType.string()._to_table_data_type(), 
DataTypes.STRING())
+
+    def test_logically_equal_types_compare_and_hash_equally(self):
+        first_int = pf.DataType.int64()
+        second_int = pf.DataType.int64()
+        string = pf.DataType.string()
+
+        self.assertEqual(first_int, second_int)
+        self.assertNotEqual(first_int, string)
+        self.assertEqual(len({first_int, second_int, string}), 2)
+
+    def test_equality_and_hash_are_public_evolving(self):
+        for method in [pf.DataType.__eq__, pf.DataType.__hash__]:
+            with self.subTest(method=method.__name__):
+                self.assertIn(
+                    PublicEvolving,
+                    getattr(method, "__stability_decorators", set()),
+                )
+
+    def test_nullability_modifiers_are_not_exposed(self):
+        for data_type in [pf.DataType.int64(), pf.DataType.string()]:
+            with self.subTest(data_type=data_type):
+                self.assertFalse(hasattr(data_type, "not_null"))
+                self.assertFalse(hasattr(data_type, "nullable"))
+
+
+if __name__ == "__main__":
+    unittest.main()
diff --git a/flink-python/pyflink/testing/test_case_utils.py 
b/flink-python/pyflink/testing/test_case_utils.py
index 2c6c754bf5e..bc547bd8d36 100644
--- a/flink-python/pyflink/testing/test_case_utils.py
+++ b/flink-python/pyflink/testing/test_case_utils.py
@@ -155,6 +155,35 @@ class PyFlinkUTTestCase(PyFlinkTestCase):
         self.t_env.get_config().set("python.fn-execution.bundle.size", "1")
 
 
+class PyFlinkDataFrameUTTestCase(PyFlinkUTTestCase):
+    """Base class for planner-backed DataFrame interface tests."""
+
+    def setUp(self) -> None:
+        from pyflink.dataframe import get_table_environment, 
set_table_environment
+
+        super().setUp()
+        previous_environment = get_table_environment()
+        self.addCleanup(set_table_environment, previous_environment)
+        set_table_environment(self.t_env)
+
+    def assert_dataframe_schema(
+        self,
+        dataframe,
+        expected_column_names,
+        expected_column_data_types=None,
+    ):
+        resolved_schema = dataframe._table.get_resolved_schema()
+        self.assertEqual(
+            list(resolved_schema.get_column_names()),
+            expected_column_names,
+        )
+        if expected_column_data_types is not None:
+            self.assertEqual(
+                resolved_schema.get_column_data_types(),
+                expected_column_data_types,
+            )
+
+
 class PyFlinkStreamTableTestCase(PyFlinkITTestCase):
     """
     Base class for table stream tests.
@@ -169,6 +198,27 @@ class PyFlinkStreamTableTestCase(PyFlinkITTestCase):
         cls.t_env.get_config().set("python.fn-execution.bundle.size", "1")
 
 
+class PyFlinkStreamDataFrameTestCase(PyFlinkStreamTableTestCase):
+    """Base class for DataFrame streaming tests."""
+
+    @classmethod
+    def setUpClass(cls):
+        from pyflink.dataframe import get_table_environment, 
set_table_environment
+
+        cls._previous_table_environment = get_table_environment()
+        super(PyFlinkStreamDataFrameTestCase, cls).setUpClass()
+        set_table_environment(cls.t_env)
+
+    @classmethod
+    def tearDownClass(cls):
+        from pyflink.dataframe import set_table_environment
+
+        try:
+            super(PyFlinkStreamDataFrameTestCase, cls).tearDownClass()
+        finally:
+            set_table_environment(cls._previous_table_environment)
+
+
 class PyFlinkBatchTableTestCase(PyFlinkITTestCase):
     """
     Base class for table batch tests.
diff --git a/flink-python/setup.py b/flink-python/setup.py
index 9354d422a5b..40a04a70718 100644
--- a/flink-python/setup.py
+++ b/flink-python/setup.py
@@ -277,6 +277,7 @@ try:
     scripts.append("pyflink/find_flink_home.py")
 
     PACKAGES = ['pyflink',
+                'pyflink.dataframe',
                 'pyflink.table',
                 'pyflink.util',
                 'pyflink.datastream',
diff --git a/flink-python/tox.ini b/flink-python/tox.ini
index 6f57a22dc26..ed09d9f4373 100644
--- a/flink-python/tox.ini
+++ b/flink-python/tox.ini
@@ -46,7 +46,7 @@ max-line-length=100
 
exclude=.tox/*,dev/*,lib/*,target/*,build/*,dist/*,pyflink/shell.py,.eggs/*,pyflink/fn_execution/tests/process_mode_test_data.py,pyflink/fn_execution/*_pb2.py*,pyflink/examples/table/basic_operations.py
 
 [mypy]
-files=pyflink/common/*.py,pyflink/table/*.py,pyflink/datastream/*.py,pyflink/metrics/*.py
+files=pyflink/common/*.py,pyflink/table/*.py,pyflink/datastream/*.py,pyflink/metrics/*.py,pyflink/dataframe/*.py
 ignore_missing_imports = True
 strict_optional=False
 

Reply via email to