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 7a28e10b0b7 [FLINK-40198][python] Add Kafka source and sink APIs to 
DataFrame API
7a28e10b0b7 is described below

commit 7a28e10b0b71d2efe41b9a407c837e3cfffd0b70
Author: niliushall <[email protected]>
AuthorDate: Thu Oct 8 16:21:18 2026 +0800

    [FLINK-40198][python] Add Kafka source and sink APIs to DataFrame API
    
    This closes #29439.
---
 .../docs/reference/pyflink.dataframe/io.rst        |  55 ++
 flink-python/pyflink/dataframe/__init__.py         |   9 +-
 flink-python/pyflink/dataframe/dataframe.py        | 115 ++++
 flink-python/pyflink/dataframe/io.py               | 534 +++++++++++++++-
 .../pyflink/dataframe/tests/test_kafka_io.py       | 677 +++++++++++++++++++++
 5 files changed, 1387 insertions(+), 3 deletions(-)

diff --git a/flink-python/docs/reference/pyflink.dataframe/io.rst 
b/flink-python/docs/reference/pyflink.dataframe/io.rst
index eaa059d4e3c..c1dd0172b20 100644
--- a/flink-python/docs/reference/pyflink.dataframe/io.rst
+++ b/flink-python/docs/reference/pyflink.dataframe/io.rst
@@ -35,6 +35,7 @@ Readers
     read_catalog_table
     read_generic
     read_json
+    read_kafka
     read_parquet
 
 Writers
@@ -48,6 +49,7 @@ Writers
     DataFrame.write_catalog_table
     DataFrame.write_generic
     DataFrame.write_json
+    DataFrame.write_kafka
     DataFrame.write_parquet
 
 Writing to multiple sinks
@@ -80,6 +82,59 @@ The sinks share job backpressure and failover; this does not 
guarantee cross-sin
 Executing the statement set clears its staged writes. Client actions such as 
``collect()``
 and ``to_pandas()`` execute separately and are not added to the statement set.
 
+Kafka sources and sinks
+-----------------------
+
+``read_kafka`` and ``write_kafka`` use Flink's Kafka SQL connector. Make the 
Kafka
+connector and the selected format JARs available to Flink before using them. 
Readers
+require a schema and either ``topic`` (a name or list of names) or 
``topic_pattern``.
+Use ``group_id`` to identify the consumer group. The default startup mode is
+``"group-offsets"``; ``startup_mode="earliest-offset"`` starts at the earliest
+available offset instead. Sources are unbounded by default; set 
``bounded_mode``
+to stop reading at a selected offset or timestamp. Topic lists and 
``key_fields``
+are converted to semicolon-separated connector options. Specific offsets can be
+supplied as ``{0: 42, 1: 300}`` or as the connector string
+``"partition:0,offset:42;partition:1,offset:300"`` for both startup and 
bounded modes.
+Set ``topic_partition_discovery_interval=None`` to disable partition discovery;
+this explicitly sets ``scan.topic-partition-discovery.interval`` to ``"0"``.
+
+For example, read JSON records from one topic and write them to another::
+
+    import pyflink.dataframe as pf
+
+    events = pf.read_kafka(
+        "localhost:9092",
+        schema={"id": pf.DataType.int64(), "event": pf.DataType.string()},
+        topic="input-events",
+        group_id="dataframe-events",
+        startup_mode="earliest-offset",
+    )
+    events.write_kafka("localhost:9092", topic="output-events")
+
+Both methods default to JSON values. ``format`` and ``value_format`` select the
+value format; ``value_format`` takes precedence when both are provided. Pass
+format-specific settings through ``format_options`` or 
``value_format_options``;
+use ``key_format`` and ``key_fields`` to configure keys. Additional Kafka 
client
+settings go in ``properties``. Writers default to ``"at-least-once"`` delivery;
+``delivery_guarantee`` also supports ``"none"`` and ``"exactly-once"``.
+Exactly-once delivery requires checkpointing and ``transactional_id_prefix``
+(or ``sink.transactional-id-prefix`` in ``options``). Use a unique prefix for 
each
+concurrently running Kafka sink. Use ``partitioner`` to select ``"default"``,
+``"fixed"``, ``"round-robin"``, or a custom ``FlinkKafkaPartitioner`` class 
name;
+``"round-robin"`` requires records without keys. Set ``parallelism`` to
+configure the Kafka writer's parallelism.
+
+Both readers and writers accept additional raw connector settings through
+``options``, for example ``options={"scan.parallelism": "3"}`` for a reader or
+``options={"sink.parallelism": "3"}`` for a writer. Unlike ``properties``, 
which
+adds the ``properties.`` prefix to Kafka client settings, ``options`` keys are
+passed through unchanged. The ``connector`` key is reserved. Conflicts with 
options
+generated by other arguments, including default values, raise ``ValueError``;
+use the dedicated parameter to change such a setting.
+
+As with the other writers, pass ``statement_set=`` to stage a Kafka write 
alongside
+other sinks instead of submitting it immediately.
+
 Filesystem sources and sinks
 ----------------------------
 
diff --git a/flink-python/pyflink/dataframe/__init__.py 
b/flink-python/pyflink/dataframe/__init__.py
index 0e67c1a9e54..e463bb19d0c 100644
--- a/flink-python/pyflink/dataframe/__init__.py
+++ b/flink-python/pyflink/dataframe/__init__.py
@@ -65,7 +65,13 @@ from pyflink.dataframe.context import (
 from pyflink.dataframe.dataframe import DataFrame, GroupedDataFrame, col, lit
 from pyflink.dataframe.dataframe_config import config
 from pyflink.dataframe.datatype import DataType
-from pyflink.dataframe.io import read_catalog_table, read_generic, read_json, 
read_parquet
+from pyflink.dataframe.io import (
+    read_catalog_table,
+    read_generic,
+    read_json,
+    read_kafka,
+    read_parquet,
+)
 from pyflink.dataframe.iteration import CloseableIterator
 from pyflink.dataframe.sql import sql
 from pyflink.dataframe.udf import udf
@@ -89,6 +95,7 @@ __all__ = [
     "read_catalog_table",
     "read_generic",
     "read_json",
+    "read_kafka",
     "read_parquet",
     "create_catalog",
     "get_catalog",
diff --git a/flink-python/pyflink/dataframe/dataframe.py 
b/flink-python/pyflink/dataframe/dataframe.py
index 3404a4d1861..66152fb4ce9 100644
--- a/flink-python/pyflink/dataframe/dataframe.py
+++ b/flink-python/pyflink/dataframe/dataframe.py
@@ -2703,6 +2703,121 @@ class DataFrame:
             statement_set=statement_set,
         )
 
+    @PublicEvolving()
+    def write_kafka(
+        self,
+        bootstrap_servers: str,
+        *,
+        topic: str,
+        format: str = "json",
+        format_options: Optional[Dict[str, str]] = None,
+        key_format: Optional[str] = None,
+        key_format_options: Optional[Dict[str, str]] = None,
+        key_fields: Optional[List[str]] = None,
+        value_format: Optional[str] = None,
+        value_format_options: Optional[Dict[str, str]] = None,
+        value_fields_include: Literal["ALL", "EXCEPT_KEY"] = "ALL",
+        delivery_guarantee: Literal[
+            "none", "at-least-once", "exactly-once"
+        ] = "at-least-once",
+        transactional_id_prefix: Optional[str] = None,
+        partitioner: Optional[str] = None,
+        properties: Optional[Dict[str, str]] = None,
+        parallelism: Optional[int] = None,
+        options: Optional[Dict[str, str]] = None,
+        statement_set: Optional[StatementSet] = None,
+    ) -> None:
+        """
+        Write this DataFrame to Kafka using Flink's Kafka SQL connector.
+
+        The Kafka connector must be available to Flink. By default, the write 
is
+        submitted immediately and waits for completion for local or MiniCluster
+        execution. Passing ``statement_set`` stages the write without 
executing it.
+        Sink columns are derived from this DataFrame's schema.
+
+        ``format`` and ``value_format`` are aliases; ``value_format`` takes
+        precedence when both are set. ``format_options`` and 
``value_format_options`` are both
+        normalized to ``value.<format>.<key>``; conflicting values are 
rejected.
+
+        :param bootstrap_servers: Comma-separated Kafka bootstrap server
+            addresses.
+        :param topic: Kafka topic to write to.
+        :param format: Value format, for example ``"json"``, ``"csv"``,
+            ``"avro"``.
+        :param format_options: Value format options with string values, with
+            or without the ``<format>.`` prefix.
+        :param key_format: Key format, for example ``"json"`` or ``"avro"``.
+        :param key_format_options: Key format options with string values, with
+            or without the ``key.<key_format>.`` prefix.
+        :param key_fields: List of column names that make up the key.
+        :param value_format: Alternative to ``format`` for specifying the
+            value format.
+        :param value_format_options: Alternative to ``format_options`` for
+            specifying value format options.
+        :param value_fields_include: Whether value fields include key fields:
+            ``"ALL"`` or ``"EXCEPT_KEY"``.
+        :param delivery_guarantee: Delivery guarantee: ``"none"``,
+            ``"at-least-once"``, or ``"exactly-once"``. Exactly-once requires
+            checkpointing and a transaction id prefix.
+        :param transactional_id_prefix: Prefix for Kafka transaction 
identifiers.
+            Required for ``"exactly-once"``, unless set via ``options``. Use a 
unique
+            prefix for each concurrently running Kafka sink.
+        :param partitioner: Kafka sink partitioner, for example ``"default"``, 
``"fixed"``,
+            ``"round-robin"``, or the fully qualified name of a custom
+            ``FlinkKafkaPartitioner`` subclass. ``None`` uses the connector 
default.
+        :param properties: Additional Kafka producer properties with string
+            keys and values. Keys may be supplied with or without the
+            ``properties.`` prefix. ``properties.bootstrap.servers`` must use
+            the dedicated argument.
+        :param parallelism: Sink parallelism. The connector default is the
+            upstream parallelism.
+        :param options: Additional raw Kafka connector options with string 
keys and values,
+            excluding ``connector``. Keys are passed through unchanged. 
Conflicts with
+            options generated by other arguments (including defaults) raise 
``ValueError``.
+        :param statement_set: Optional :class:`~pyflink.table.StatementSet` to 
stage the write in.
+            It must use the same TableEnvironment as this DataFrame. Call its 
``execute()``
+            method to submit all staged writes together.
+
+        :raises TypeError: If an argument has an invalid type.
+        :raises ValueError: If required arguments are missing, mutually
+            exclusive arguments conflict, or an option is invalid.
+
+        Example::
+
+            >>> import pyflink.dataframe as pf
+            >>> events = pf.from_records(
+            ...     [(1, "login")], schema=["user_id", "event"])
+            >>> events.write_kafka(
+            ...     "localhost:9092",
+            ...     topic="output-events",
+            ...     format="json",
+            ...     delivery_guarantee="at-least-once",
+            ... )
+
+        .. versionadded:: 2.4.0
+        """
+        from pyflink.dataframe.io import _build_kafka_sink_options
+
+        kafka_options = _build_kafka_sink_options(
+            bootstrap_servers,
+            topic=topic,
+            value_format=value_format if value_format is not None else format,
+            format=format,
+            format_options=format_options,
+            value_format_options=value_format_options,
+            key_format=key_format,
+            key_format_options=key_format_options,
+            key_fields=key_fields,
+            value_fields_include=value_fields_include,
+            delivery_guarantee=delivery_guarantee,
+            transactional_id_prefix=transactional_id_prefix,
+            partitioner=partitioner,
+            properties=properties,
+            parallelism=parallelism,
+            extra_options=options,
+        )
+        self._write("kafka", kafka_options, statement_set=statement_set)
+
     @PublicEvolving()
     def write_generic(
         self,
diff --git a/flink-python/pyflink/dataframe/io.py 
b/flink-python/pyflink/dataframe/io.py
index 14b2ba206a7..0c46c8eb3a5 100644
--- a/flink-python/pyflink/dataframe/io.py
+++ b/flink-python/pyflink/dataframe/io.py
@@ -16,7 +16,7 @@
 # limitations under the License.
 
################################################################################
 
-from typing import Dict, List, Optional, Tuple, Union
+from typing import Dict, List, Literal, Optional, Tuple, Union
 
 from pyflink.dataframe.catalog import _validate_name
 from pyflink.dataframe.context import get_or_create_table_environment
@@ -27,7 +27,7 @@ from pyflink.java_gateway import get_gateway
 from pyflink.table import Schema, Table, TableDescriptor
 from pyflink.util.api_stability_decorators import PublicEvolving
 
-__all__ = ["read_catalog_table", "read_generic", "read_json", "read_parquet"]
+__all__ = ["read_catalog_table", "read_generic", "read_json", "read_kafka", 
"read_parquet"]
 
 
 def _build_filesystem_options(
@@ -276,6 +276,427 @@ def read_json(
     )
 
 
+def _normalize_kafka_properties(
+    properties: Optional[Dict[str, str]], *, reserved: tuple
+) -> Optional[Dict[str, str]]:
+    if properties is None:
+        return None
+    _validate_options(properties)
+    normalized = {
+        key if key.startswith("properties.") else f"properties.{key}": value
+        for key, value in properties.items()
+    }
+    for key in reserved:
+        if key in normalized:
+            raise ValueError(
+                f"{key!r} must be configured through its dedicated DataFrame "
+                f"argument, not through properties")
+    return normalized
+
+
+def _merge_kafka_format_options(
+    options: Dict[str, str],
+    value_format: str,
+    *,
+    format_options: Optional[Dict[str, str]],
+    value_format_options: Optional[Dict[str, str]],
+) -> None:
+    normalized_format_options = _normalize_kafka_format_options(
+        format_options, value_format=value_format)
+    normalized_value_format_options = _normalize_kafka_format_options(
+        value_format_options, value_format=value_format)
+    _merge_options(options, normalized_format_options)
+    _merge_options(options, normalized_value_format_options)
+
+
+def _merge_kafka_key_format_options(
+    options: Dict[str, str],
+    key_format: str,
+    key_format_options: Optional[Dict[str, str]],
+) -> None:
+    if key_format_options is None:
+        return
+    _validate_options(key_format_options)
+    normalized = {
+        key if key.startswith("key.") else f"key.{key_format}.{key}": value
+        for key, value in key_format_options.items()
+    }
+    _merge_options(options, normalized)
+
+
+def _normalize_kafka_format_options(
+    options: Optional[Dict[str, str]],
+    *,
+    value_format: str,
+) -> Dict[str, str]:
+    if options is None:
+        return {}
+    _validate_options(options)
+    value_prefix = f"value.{value_format}."
+    normalized: Dict[str, str] = {}
+    for key, value in options.items():
+        if key.startswith(value_prefix):
+            normalized[key] = value
+        elif key.startswith(f"{value_format}."):
+            normalized[value_prefix + key[len(f"{value_format}."):]] = value
+        else:
+            normalized[value_prefix + key] = value
+    return normalized
+
+
+def _build_kafka_options(
+    bootstrap_servers: str,
+    *,
+    topic: Optional[Union[str, List[str]]],
+    topic_pattern: Optional[str],
+    group_id: Optional[str],
+    value_format: str,
+    format_options: Optional[Dict[str, str]],
+    value_format_options: Optional[Dict[str, str]],
+    key_format: Optional[str],
+    key_format_options: Optional[Dict[str, str]],
+    key_fields: Optional[List[str]],
+    key_fields_prefix: Optional[str],
+    value_fields_include: str,
+    startup_mode: str,
+    startup_specific_offsets: Optional[Union[str, Dict[int, int]]],
+    startup_timestamp_millis: Optional[int],
+    topic_partition_discovery_interval: Optional[str],
+    bounded_mode: str,
+    bounded_timestamp_millis: Optional[int],
+    bounded_specific_offsets: Optional[Union[str, Dict[int, int]]],
+    properties: Optional[Dict[str, str]],
+    extra_options: Optional[Dict[str, str]] = None,
+) -> Dict[str, str]:
+    if not isinstance(bootstrap_servers, str):
+        raise TypeError("bootstrap_servers must be a string")
+    if not bootstrap_servers:
+        raise ValueError("bootstrap_servers must not be empty")
+
+    if topic is None and topic_pattern is None:
+        raise ValueError("either 'topic' or 'topic_pattern' must be specified")
+    if topic is not None and topic_pattern is not None:
+        raise ValueError("'topic' and 'topic_pattern' are mutually exclusive")
+    if topic is not None:
+        if isinstance(topic, str):
+            resolved_topic = topic
+        elif isinstance(topic, list):
+            if not topic:
+                raise ValueError("topic must not be an empty list")
+            if not all(isinstance(t, str) for t in topic):
+                raise TypeError("topic list elements must be strings")
+            resolved_topic = ";".join(topic)
+        else:
+            raise TypeError("topic must be a string or a list of strings")
+    else:
+        resolved_topic = None
+    if topic_pattern is not None and not isinstance(topic_pattern, str):
+        raise TypeError("topic_pattern must be a string")
+
+    if group_id is not None and not isinstance(group_id, str):
+        raise TypeError("group_id must be a string")
+
+    if not isinstance(value_format, str):
+        raise TypeError("format must be a string")
+    if not value_format:
+        raise ValueError("format must not be empty")
+
+    _validate_literal(value_fields_include, ("ALL", "EXCEPT_KEY"), 
"value_fields_include")
+    _validate_literal(startup_mode, _STARTUP_MODES, "startup_mode")
+    _validate_literal(bounded_mode, _BOUNDED_MODES, "bounded_mode")
+
+    if startup_mode == "specific-offsets":
+        startup_specific_offsets = _convert_specific_offsets(
+            startup_specific_offsets, "startup_specific_offsets")
+        if startup_specific_offsets is None:
+            raise ValueError(
+                "startup_specific_offsets is required when "
+                "startup_mode='specific-offsets'")
+    else:
+        startup_specific_offsets = None
+    if startup_mode == "timestamp" and startup_timestamp_millis is None:
+        raise ValueError(
+            "startup_timestamp_millis is required when 
startup_mode='timestamp'")
+    if (
+        startup_timestamp_millis is not None
+        and (
+            isinstance(startup_timestamp_millis, bool)
+            or not isinstance(startup_timestamp_millis, int)
+        )
+    ):
+        raise TypeError("startup_timestamp_millis must be an int")
+
+    if bounded_mode == "specific-offsets":
+        bounded_specific_offsets = _convert_specific_offsets(
+            bounded_specific_offsets, "bounded_specific_offsets")
+        if bounded_specific_offsets is None:
+            raise ValueError(
+                "bounded_specific_offsets is required when "
+                "bounded_mode='specific-offsets'")
+    else:
+        bounded_specific_offsets = None
+    if bounded_mode == "timestamp" and bounded_timestamp_millis is None:
+        raise ValueError(
+            "bounded_timestamp_millis is required when 
bounded_mode='timestamp'")
+    if (
+        bounded_timestamp_millis is not None
+        and (
+            isinstance(bounded_timestamp_millis, bool)
+            or not isinstance(bounded_timestamp_millis, int)
+        )
+    ):
+        raise TypeError("bounded_timestamp_millis must be an int")
+
+    if (
+        topic_partition_discovery_interval is not None
+        and not isinstance(topic_partition_discovery_interval, str)
+    ):
+        raise TypeError("topic_partition_discovery_interval must be a string")
+
+    if key_format is not None and not isinstance(key_format, str):
+        raise TypeError("key_format must be a string")
+    if key_fields is not None:
+        if not isinstance(key_fields, list):
+            raise TypeError("key_fields must be a list of strings")
+        if not key_fields:
+            raise ValueError("key_fields must not be empty")
+        if not all(isinstance(f, str) for f in key_fields):
+            raise TypeError("key_fields elements must be strings")
+    if key_fields_prefix is not None and not isinstance(key_fields_prefix, 
str):
+        raise TypeError("key_fields_prefix must be a string")
+
+    normalized_properties = _normalize_kafka_properties(
+        properties, reserved=("properties.bootstrap.servers", 
"properties.group.id"))
+
+    options: Dict[str, str] = {
+        "properties.bootstrap.servers": bootstrap_servers,
+        "value.format": value_format,
+        "value.fields-include": value_fields_include,
+    }
+    if group_id is not None:
+        options["properties.group.id"] = group_id
+    if resolved_topic is not None:
+        options["topic"] = resolved_topic
+    if topic_pattern is not None:
+        options["topic-pattern"] = topic_pattern
+    options["scan.topic-partition-discovery.interval"] = (
+        topic_partition_discovery_interval
+        if topic_partition_discovery_interval is not None else "0")
+    options["scan.startup.mode"] = startup_mode
+    if startup_specific_offsets is not None:
+        options["scan.startup.specific-offsets"] = startup_specific_offsets
+    if startup_timestamp_millis is not None:
+        options["scan.startup.timestamp-millis"] = 
str(startup_timestamp_millis)
+    if bounded_mode != "unbounded":
+        options["scan.bounded.mode"] = bounded_mode
+        if bounded_timestamp_millis is not None:
+            options["scan.bounded.timestamp-millis"] = 
str(bounded_timestamp_millis)
+        if bounded_specific_offsets is not None:
+            options["scan.bounded.specific-offsets"] = bounded_specific_offsets
+
+    if key_format is not None:
+        options["key.format"] = key_format
+    _merge_kafka_key_format_options(options, key_format, key_format_options)
+    if key_fields is not None:
+        options["key.fields"] = ";".join(key_fields)
+    if key_fields_prefix is not None:
+        options["key.fields-prefix"] = key_fields_prefix
+
+    _merge_kafka_format_options(
+        options,
+        value_format,
+        format_options=format_options,
+        value_format_options=value_format_options,
+    )
+
+    if normalized_properties is not None:
+        _merge_options(options, normalized_properties)
+    if extra_options is not None:
+        _merge_options(options, extra_options)
+
+    return options
+
+
+_STARTUP_MODES = (
+    "earliest-offset", "latest-offset", "group-offsets",
+    "timestamp", "specific-offsets")
+_BOUNDED_MODES = (
+    "unbounded", "group-offsets", "latest-offset",
+    "timestamp", "specific-offsets")
+_DELIVERY_GUARANTEES = ("none", "at-least-once", "exactly-once")
+
+
+def _validate_literal(value: str, choices: tuple, name: str) -> None:
+    if not isinstance(value, str):
+        raise TypeError(f"{name} must be a string")
+    if value not in choices:
+        raise ValueError(
+            f"{name} must be one of {choices}, got {value!r}")
+
+
+def _convert_specific_offsets(
+    value: Optional[Union[str, Dict[int, int]]], name: str
+) -> Optional[str]:
+    if value is None:
+        return None
+    if isinstance(value, str):
+        return value
+    if isinstance(value, dict):
+        parts = []
+        for partition, offset in value.items():
+            if not isinstance(partition, int) or isinstance(partition, bool):
+                raise TypeError(f"{name} keys must be ints (partition 
numbers)")
+            if not isinstance(offset, int) or isinstance(offset, bool):
+                raise TypeError(f"{name} values must be ints (offsets)")
+            parts.append(f"partition:{partition},offset:{offset}")
+        return ";".join(parts)
+    raise TypeError(f"{name} must be a str or a Dict[int, int]")
+
+
+@PublicEvolving()
+def read_kafka(
+    bootstrap_servers: str,
+    *,
+    schema: Dict[str, DataType],
+    topic: Optional[Union[str, List[str]]] = None,
+    topic_pattern: Optional[str] = None,
+    group_id: Optional[str] = None,
+    format: str = "json",
+    format_options: Optional[Dict[str, str]] = None,
+    key_format: Optional[str] = None,
+    key_format_options: Optional[Dict[str, str]] = None,
+    key_fields: Optional[List[str]] = None,
+    key_fields_prefix: Optional[str] = None,
+    value_format: Optional[str] = None,
+    value_format_options: Optional[Dict[str, str]] = None,
+    value_fields_include: Literal["ALL", "EXCEPT_KEY"] = "ALL",
+    startup_mode: Literal[
+        "earliest-offset", "latest-offset", "group-offsets",
+        "timestamp", "specific-offsets"
+    ] = "group-offsets",
+    startup_specific_offsets: Optional[Union[str, Dict[int, int]]] = None,
+    startup_timestamp_millis: Optional[int] = None,
+    topic_partition_discovery_interval: Optional[str] = "5 min",
+    bounded_mode: Literal[
+        "unbounded", "group-offsets", "latest-offset",
+        "timestamp", "specific-offsets"
+    ] = "unbounded",
+    bounded_timestamp_millis: Optional[int] = None,
+    bounded_specific_offsets: Optional[Union[str, Dict[int, int]]] = None,
+    properties: Optional[Dict[str, str]] = None,
+    options: Optional[Dict[str, str]] = None,
+    computed_columns: Optional[Dict[str, str]] = None,
+    watermark: Optional[Tuple[str, str]] = None,
+) -> DataFrame:
+    """
+    Read data from Kafka using Flink's Kafka SQL connector.
+
+    The Kafka connector must be available to Flink. Exactly one of ``topic`` or
+    ``topic_pattern`` must be specified. Physical columns in ``schema`` are 
followed by
+    computed columns in dictionary insertion order. A watermark can reference 
a physical
+    or computed timestamp column.
+
+    ``format`` and ``value_format`` are aliases; ``value_format`` takes 
precedence when
+    both are set.
+    ``format_options`` and ``value_format_options`` are both normalized to
+    ``value.<format>.<key>``; conflicting values are rejected.
+
+    :param bootstrap_servers: Comma-separated Kafka bootstrap server addresses.
+    :param schema: Non-empty mapping of physical column names to DataFrame 
data types.
+    :param topic: Kafka topic name or list of names. Mutually exclusive with
+        ``topic_pattern``.
+    :param topic_pattern: Regular expression matching Kafka topic names. 
Mutually
+        exclusive with ``topic``.
+    :param group_id: Kafka consumer group id. Maps to ``properties.group.id``.
+    :param format: Value format, for example ``"json"``, ``"csv"``, ``"avro"``.
+    :param format_options: Value format options with string values, with or 
without
+        the ``<format>.`` prefix.
+    :param key_format: Key format, for example ``"json"`` or ``"avro"``.
+    :param key_format_options: Key format options with string values, with or 
without
+        the ``key.<key_format>.`` prefix.
+    :param key_fields: List of column names that make up the key.
+    :param key_fields_prefix: Prefix for key fields to avoid name clashes with 
value
+        fields.
+    :param value_format: Alternative to ``format`` for specifying the value 
format.
+    :param value_format_options: Alternative to ``format_options`` for 
specifying
+        value format options.
+    :param value_fields_include: Whether value fields include key fields:
+        ``"ALL"`` or ``"EXCEPT_KEY"``.
+    :param startup_mode: Startup mode: ``"earliest-offset"``, 
``"latest-offset"``,
+        ``"group-offsets"``, ``"timestamp"``, or ``"specific-offsets"``.
+    :param startup_specific_offsets: Specific offsets for ``startup_mode`` of
+        ``"specific-offsets"``, as a string
+        (``"partition:0,offset:42;partition:1,offset:300"``) or a dict
+        (``{partition: offset}``).
+    :param startup_timestamp_millis: Startup timestamp in milliseconds for
+        ``startup_mode`` of ``"timestamp"``.
+    :param topic_partition_discovery_interval: Interval for topic partition
+        discovery, for example ``"5 min"``. Set to ``None`` to disable by 
setting
+        ``scan.topic-partition-discovery.interval`` to ``"0"``.
+    :param bounded_mode: Bounded mode: ``"unbounded"``, ``"group-offsets"``,
+        ``"latest-offset"``, ``"timestamp"``, or ``"specific-offsets"``.
+    :param bounded_timestamp_millis: Bounded timestamp in milliseconds for
+        ``bounded_mode`` of ``"timestamp"``.
+    :param bounded_specific_offsets: Specific offsets for ``bounded_mode`` of
+        ``"specific-offsets"``, using the same string or dict syntax as
+        ``startup_specific_offsets``.
+    :param properties: Additional Kafka consumer properties with string keys 
and
+        values. Keys may be supplied with or without the ``properties.`` 
prefix.
+        ``properties.bootstrap.servers`` and ``properties.group.id`` must use
+        their dedicated arguments.
+    :param options: Additional raw Kafka connector options with string keys 
and values,
+        excluding ``connector``. Keys are passed through unchanged. Conflicts 
with
+        options generated by other arguments (including defaults) raise 
``ValueError``.
+    :param computed_columns: Optional SQL expressions keyed by computed column 
name.
+    :param watermark: Optional ``(column, expression)`` watermark declaration.
+    :return: A DataFrame backed by the Kafka source.
+    :raises TypeError: If an argument has an invalid type.
+    :raises ValueError: If required arguments are missing, mutually exclusive
+        arguments conflict, or an option is invalid.
+
+    Example::
+
+        >>> import pyflink.dataframe as pf
+        >>> events = pf.read_kafka(
+        ...     "localhost:9092",
+        ...     schema={"user_id": pf.DataType.int64(), "event": 
pf.DataType.string()},
+        ...     topic="events",
+        ...     format="json",
+        ...     startup_mode="earliest-offset",
+        ... )
+
+    .. versionadded:: 2.4.0
+    """
+    effective_value_format = value_format or format
+    kafka_options = _build_kafka_options(
+        bootstrap_servers,
+        topic=topic,
+        topic_pattern=topic_pattern,
+        group_id=group_id,
+        value_format=effective_value_format,
+        format_options=format_options,
+        value_format_options=value_format_options,
+        key_format=key_format,
+        key_format_options=key_format_options,
+        key_fields=key_fields,
+        key_fields_prefix=key_fields_prefix,
+        value_fields_include=value_fields_include,
+        startup_mode=startup_mode,
+        startup_specific_offsets=startup_specific_offsets,
+        startup_timestamp_millis=startup_timestamp_millis,
+        topic_partition_discovery_interval=topic_partition_discovery_interval,
+        bounded_mode=bounded_mode,
+        bounded_timestamp_millis=bounded_timestamp_millis,
+        bounded_specific_offsets=bounded_specific_offsets,
+        properties=properties,
+        extra_options=options,
+    )
+    return _read(
+        "kafka", schema=schema, options=kafka_options,
+        computed_columns=computed_columns, watermark=watermark,
+    )
+
+
 def _validate_connector(connector: str) -> None:
     if not isinstance(connector, str):
         raise TypeError("connector must be a string")
@@ -608,3 +1029,112 @@ def read_catalog_table(
     if validated_columns or validated_watermark is not None:
         table = _extend_catalog_table(table, validated_columns, 
validated_watermark)
     return DataFrame(table)
+
+
+def _build_kafka_sink_options(
+    bootstrap_servers: str,
+    *,
+    topic: str,
+    value_format: str,
+    format: str,
+    format_options: Optional[Dict[str, str]],
+    value_format_options: Optional[Dict[str, str]],
+    key_format: Optional[str],
+    key_format_options: Optional[Dict[str, str]],
+    key_fields: Optional[List[str]],
+    value_fields_include: str,
+    delivery_guarantee: str,
+    properties: Optional[Dict[str, str]],
+    parallelism: Optional[int],
+    transactional_id_prefix: Optional[str] = None,
+    partitioner: Optional[str] = None,
+    extra_options: Optional[Dict[str, str]] = None,
+) -> Dict[str, str]:
+    if not isinstance(bootstrap_servers, str):
+        raise TypeError("bootstrap_servers must be a string")
+    if not bootstrap_servers:
+        raise ValueError("bootstrap_servers must not be empty")
+
+    if not isinstance(topic, str):
+        raise TypeError("topic must be a string")
+    if not topic:
+        raise ValueError("topic must not be empty")
+
+    if not isinstance(value_format, str):
+        raise TypeError("format must be a string")
+    if not value_format:
+        raise ValueError("format must not be empty")
+
+    _validate_literal(value_fields_include, ("ALL", "EXCEPT_KEY"), 
"value_fields_include")
+    _validate_literal(delivery_guarantee, _DELIVERY_GUARANTEES, 
"delivery_guarantee")
+
+    if key_format is not None and not isinstance(key_format, str):
+        raise TypeError("key_format must be a string")
+    if key_fields is not None:
+        if not isinstance(key_fields, list):
+            raise TypeError("key_fields must be a list of strings")
+        if not key_fields:
+            raise ValueError("key_fields must not be empty")
+        if not all(isinstance(f, str) for f in key_fields):
+            raise TypeError("key_fields elements must be strings")
+    if key_format_options is not None:
+        _validate_options(key_format_options)
+
+    if parallelism is not None:
+        if isinstance(parallelism, bool) or not isinstance(parallelism, int):
+            raise TypeError("parallelism must be an int or None")
+
+    if transactional_id_prefix is not None:
+        if not isinstance(transactional_id_prefix, str):
+            raise TypeError("transactional_id_prefix must be a string")
+        if not transactional_id_prefix:
+            raise ValueError("transactional_id_prefix must not be empty")
+    if partitioner is not None:
+        if not isinstance(partitioner, str):
+            raise TypeError("partitioner must be a string")
+        if not partitioner:
+            raise ValueError("partitioner must not be empty")
+
+    normalized_properties = _normalize_kafka_properties(
+        properties, reserved=("properties.bootstrap.servers",))
+
+    options: Dict[str, str] = {
+        "properties.bootstrap.servers": bootstrap_servers,
+        "topic": topic,
+        "value.format": value_format,
+        "value.fields-include": value_fields_include,
+        "sink.delivery-guarantee": delivery_guarantee,
+    }
+    if transactional_id_prefix is not None:
+        options["sink.transactional-id-prefix"] = transactional_id_prefix
+    if partitioner is not None:
+        options["sink.partitioner"] = partitioner
+    if key_format is not None:
+        options["key.format"] = key_format
+    _merge_kafka_key_format_options(options, key_format, key_format_options)
+    if key_fields is not None:
+        options["key.fields"] = ";".join(key_fields)
+
+    _merge_kafka_format_options(
+        options,
+        value_format,
+        format_options=format_options,
+        value_format_options=value_format_options,
+    )
+
+    if parallelism is not None:
+        options["sink.parallelism"] = str(parallelism)
+
+    if normalized_properties is not None:
+        _merge_options(options, normalized_properties)
+    if extra_options is not None:
+        _merge_options(options, extra_options)
+
+    if (
+        options["sink.delivery-guarantee"] == "exactly-once"
+        and not options.get("sink.transactional-id-prefix")
+    ):
+        raise ValueError(
+            "sink.transactional-id-prefix is required when 
delivery_guarantee='exactly-once'")
+
+    return options
diff --git a/flink-python/pyflink/dataframe/tests/test_kafka_io.py 
b/flink-python/pyflink/dataframe/tests/test_kafka_io.py
new file mode 100644
index 00000000000..70f2fd85246
--- /dev/null
+++ b/flink-python/pyflink/dataframe/tests/test_kafka_io.py
@@ -0,0 +1,677 @@
+################################################################################
+#  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 inspect
+import unittest
+from unittest.mock import MagicMock, patch
+
+import pyflink.dataframe as pf
+from pyflink.dataframe import DataType
+from pyflink.dataframe.io import (
+    _build_kafka_options,
+    _build_kafka_sink_options,
+    _convert_specific_offsets,
+)
+from pyflink.testing.test_case_utils import PyFlinkDataFrameUTTestCase
+
+
+class KafkaOptionTests(unittest.TestCase):
+    def test_reader_schema_is_first_keyword_only_parameter(self):
+        parameters = inspect.signature(pf.read_kafka).parameters
+        self.assertEqual(list(parameters)[:2], ["bootstrap_servers", "schema"])
+        self.assertEqual(parameters["schema"].kind, 
inspect.Parameter.KEYWORD_ONLY)
+        self.assertIs(parameters["schema"].default, inspect.Parameter.empty)
+
+    def test_writer_uses_parallelism_parameter(self):
+        parameters = inspect.signature(pf.DataFrame.write_kafka).parameters
+        self.assertIn("parallelism", parameters)
+        self.assertNotIn("sink_parallelism", parameters)
+        self.assertEqual(parameters["parallelism"].kind, 
inspect.Parameter.KEYWORD_ONLY)
+        self.assertIsNone(parameters["parallelism"].default)
+
+    def _source_options(self, **overrides):
+        arguments = {
+            "bootstrap_servers": "localhost:9092", "topic": "events", 
"topic_pattern": None,
+            "group_id": None, "value_format": "json", "format_options": None,
+            "value_format_options": None, "key_format": None, 
"key_format_options": None,
+            "key_fields": None, "key_fields_prefix": None, 
"value_fields_include": "ALL",
+            "startup_mode": "group-offsets", "startup_specific_offsets": None,
+            "startup_timestamp_millis": None, 
"topic_partition_discovery_interval": "5 min",
+            "bounded_mode": "unbounded", "bounded_timestamp_millis": None,
+            "bounded_specific_offsets": None, "properties": None,
+        }
+        arguments.update(overrides)
+        return _build_kafka_options(**arguments)
+
+    def _sink_options(self, **overrides):
+        arguments = {
+            "bootstrap_servers": "localhost:9092", "topic": "events", 
"value_format": "json",
+            "format": "json", "format_options": None, "value_format_options": 
None,
+            "key_format": None, "key_format_options": None, "key_fields": None,
+            "value_fields_include": "ALL", "delivery_guarantee": 
"at-least-once",
+            "properties": None, "parallelism": None,
+        }
+        arguments.update(overrides)
+        return _build_kafka_sink_options(**arguments)
+
+    def test_source_discovery_interval(self):
+        for interval, expected in ((None, "0"), ("0", "0"), ("5 min", "5 
min")):
+            with self.subTest(interval=interval):
+                options = 
self._source_options(topic_partition_discovery_interval=interval)
+                
self.assertEqual(options["scan.topic-partition-discovery.interval"], expected)
+
+    def test_bounded_specific_offsets(self):
+        options = self._source_options(
+            bounded_mode="specific-offsets", bounded_specific_offsets={0: 10, 
1: 20})
+        self.assertEqual(options["scan.bounded.specific-offsets"],
+                         "partition:0,offset:10;partition:1,offset:20")
+
+    def test_sink_transactional_id_prefix_and_partitioner(self):
+        options = self._sink_options(
+            delivery_guarantee="exactly-once", 
transactional_id_prefix="dataframe-events",
+            partitioner="fixed")
+        self.assertEqual(options["sink.transactional-id-prefix"], 
"dataframe-events")
+        self.assertEqual(options["sink.partitioner"], "fixed")
+        defaults = self._sink_options()
+        self.assertNotIn("sink.transactional-id-prefix", defaults)
+        self.assertNotIn("sink.partitioner", defaults)
+        custom = 
self._sink_options(partitioner="org.example.CustomPartitioner")
+        self.assertEqual(custom["sink.partitioner"], 
"org.example.CustomPartitioner")
+
+    def test_sink_rejects_missing_or_invalid_transaction_options(self):
+        for overrides, error_type, message in (
+            ({"delivery_guarantee": "exactly-once"}, ValueError, 
"transactional-id-prefix"),
+            ({"transactional_id_prefix": 1}, TypeError, 
"transactional_id_prefix"),
+            ({"transactional_id_prefix": ""}, ValueError, 
"transactional_id_prefix"),
+            ({"partitioner": 1}, TypeError, "partitioner"),
+            ({"partitioner": ""}, ValueError, "partitioner"),
+            ({"delivery_guarantee": "exactly-once",
+              "extra_options": {"sink.transactional-id-prefix": ""}}, 
ValueError,
+             "transactional-id-prefix"),
+        ):
+            with self.subTest(overrides=overrides):
+                with self.assertRaisesRegex(error_type, message):
+                    self._sink_options(**overrides)
+
+    def test_raw_connector_options(self):
+        source = self._source_options(
+            extra_options={"scan.parallelism": "3", "properties.client.id": 
"reader"})
+        self.assertEqual(source["scan.parallelism"], "3")
+        self.assertEqual(source["properties.client.id"], "reader")
+        sink = self._sink_options(delivery_guarantee="exactly-once", 
extra_options={
+            "sink.transactional-id-prefix": "writer", "sink.partitioner": 
"round-robin"})
+        self.assertEqual(sink["sink.transactional-id-prefix"], "writer")
+        self.assertEqual(sink["sink.partitioner"], "round-robin")
+
+    def test_raw_options_reject_invalid_or_conflicting_values(self):
+        for build in (self._source_options, self._sink_options):
+            raw_options = {"topic": "events", "properties.client.id": "reader"}
+            original_options = raw_options.copy()
+            build(extra_options=raw_options)
+            self.assertEqual(raw_options, original_options)
+            for extra, error_type in (
+                ([], TypeError), ({"scan.parallelism": 3}, TypeError), ({1: 
"value"}, TypeError),
+                ({"": "value"}, ValueError), ({"connector": "filesystem"}, 
ValueError),
+                ({"topic": "other"}, ValueError), ({"value.format": "csv"}, 
ValueError),
+                ({"properties.bootstrap.servers": "other:9092"}, ValueError),
+            ):
+                with self.subTest(build=build.__name__, extra=extra):
+                    with self.assertRaises(error_type):
+                        build(extra_options=extra)
+            self.assertEqual(build(extra_options={"topic": 
"events"})["topic"], "events")
+            with self.assertRaisesRegex(ValueError, "properties.client.id"):
+                build(properties={"client.id": "one"},
+                      extra_options={"properties.client.id": "two"})
+        with self.assertRaisesRegex(ValueError, "sink.partitioner"):
+            self._sink_options(partitioner="fixed", 
extra_options={"sink.partitioner": "default"})
+        with self.assertRaisesRegex(ValueError, 
"sink.transactional-id-prefix"):
+            self._sink_options(transactional_id_prefix="one",
+                               extra_options={"sink.transactional-id-prefix": 
"two"})
+        with self.assertRaisesRegex(ValueError, "scan.startup.mode"):
+            self._source_options(extra_options={"scan.startup.mode": 
"latest-offset"})
+
+    def test_builds_source_options(self):
+        options = _build_kafka_options(
+            "localhost:9092",
+            topic="events",
+            topic_pattern=None,
+            group_id="group",
+            value_format="json",
+            format_options={"ignore-parse-errors": "true"},
+            value_format_options=None,
+            key_format="json",
+            key_format_options={"ignore-parse-errors": "false"},
+            key_fields=["id", "name"],
+            key_fields_prefix="k_",
+            value_fields_include="EXCEPT_KEY",
+            startup_mode="specific-offsets",
+            startup_specific_offsets={0: 10, 1: 20},
+            startup_timestamp_millis=None,
+            topic_partition_discovery_interval="1 min",
+            bounded_mode="timestamp",
+            bounded_timestamp_millis=123456,
+            bounded_specific_offsets=None,
+            properties={"enable.idempotence": "true"},
+        )
+
+        self.assertEqual(options, {
+            "properties.bootstrap.servers": "localhost:9092",
+            "properties.group.id": "group",
+            "topic": "events",
+            "value.format": "json",
+            "value.json.ignore-parse-errors": "true",
+            "key.format": "json",
+            "key.json.ignore-parse-errors": "false",
+            "key.fields": "id;name",
+            "key.fields-prefix": "k_",
+            "value.fields-include": "EXCEPT_KEY",
+            "scan.startup.mode": "specific-offsets",
+            "scan.startup.specific-offsets": 
"partition:0,offset:10;partition:1,offset:20",
+            "scan.topic-partition-discovery.interval": "1 min",
+            "scan.bounded.mode": "timestamp",
+            "scan.bounded.timestamp-millis": "123456",
+            "properties.enable.idempotence": "true",
+        })
+
+    def test_source_topic_list_is_semicolon_joined(self):
+        options = _build_kafka_options(
+            "localhost:9092",
+            topic=["events-1", "events-2"],
+            topic_pattern=None,
+            group_id=None,
+            value_format="json",
+            format_options=None,
+            value_format_options=None,
+            key_format=None,
+            key_format_options=None,
+            key_fields=None,
+            key_fields_prefix=None,
+            value_fields_include="ALL",
+            startup_mode="group-offsets",
+            startup_specific_offsets=None,
+            startup_timestamp_millis=None,
+            topic_partition_discovery_interval=None,
+            bounded_mode="unbounded",
+            bounded_timestamp_millis=None,
+            bounded_specific_offsets=None,
+            properties=None,
+        )
+
+        self.assertEqual(options["topic"], "events-1;events-2")
+        self.assertNotIn("topic-pattern", options)
+
+    def test_source_topic_pattern(self):
+        options = _build_kafka_options(
+            "localhost:9092",
+            topic=None,
+            topic_pattern="events-.*",
+            group_id=None,
+            value_format="json",
+            format_options=None,
+            value_format_options=None,
+            key_format=None,
+            key_format_options=None,
+            key_fields=None,
+            key_fields_prefix=None,
+            value_fields_include="ALL",
+            startup_mode="group-offsets",
+            startup_specific_offsets=None,
+            startup_timestamp_millis=None,
+            topic_partition_discovery_interval=None,
+            bounded_mode="unbounded",
+            bounded_timestamp_millis=None,
+            bounded_specific_offsets=None,
+            properties=None,
+        )
+
+        self.assertEqual(options["topic-pattern"], "events-.*")
+        self.assertNotIn("topic", options)
+
+    def test_source_rejects_conflicting_format_option_aliases(self):
+        with self.assertRaisesRegex(ValueError, "conflicting values"):
+            _build_kafka_options(
+                "localhost:9092",
+                topic="events",
+                topic_pattern=None,
+                group_id=None,
+                value_format="json",
+                format_options={"ignore-parse-errors": "true"},
+                value_format_options={"ignore-parse-errors": "false"},
+                key_format=None,
+                key_format_options=None,
+                key_fields=None,
+                key_fields_prefix=None,
+                value_fields_include="ALL",
+                startup_mode="group-offsets",
+                startup_specific_offsets=None,
+                startup_timestamp_millis=None,
+                topic_partition_discovery_interval=None,
+                bounded_mode="unbounded",
+                bounded_timestamp_millis=None,
+                bounded_specific_offsets=None,
+                properties=None,
+            )
+
+    def test_source_rejects_invalid_arguments(self):
+        base_arguments = {
+            "topic": "events",
+            "topic_pattern": None,
+            "group_id": None,
+            "value_format": "json",
+            "format_options": None,
+            "value_format_options": None,
+            "key_format": None,
+            "key_format_options": None,
+            "key_fields": None,
+            "key_fields_prefix": None,
+            "value_fields_include": "ALL",
+            "startup_mode": "group-offsets",
+            "startup_specific_offsets": None,
+            "startup_timestamp_millis": None,
+            "topic_partition_discovery_interval": None,
+            "bounded_mode": "unbounded",
+            "bounded_timestamp_millis": None,
+            "bounded_specific_offsets": None,
+            "properties": None,
+        }
+        cases = [
+            ({"bootstrap_servers": None}, TypeError, "bootstrap_servers must 
be a string"),
+            ({"bootstrap_servers": ""}, ValueError, "bootstrap_servers must 
not be empty"),
+            ({"topic": None}, ValueError, "either 'topic' or 'topic_pattern'"),
+            ({"topic": "a", "topic_pattern": "b"}, ValueError, "mutually 
exclusive"),
+            ({"topic": []}, ValueError, "topic must not be an empty list"),
+            ({"topic": [1]}, TypeError, "topic list elements must be strings"),
+            ({"topic": 1}, TypeError, "topic must be a string"),
+            ({"topic": None, "topic_pattern": 1}, TypeError, "topic_pattern 
must be a string"),
+            ({"group_id": 1}, TypeError, "group_id must be a string"),
+            ({"value_fields_include": "BAD"}, ValueError, 
"value_fields_include"),
+            ({"startup_mode": "bad"}, ValueError, "startup_mode"),
+            ({"startup_mode": "specific-offsets"}, ValueError, 
"startup_specific_offsets"),
+            ({"startup_mode": "timestamp"}, ValueError, 
"startup_timestamp_millis"),
+            ({"startup_mode": "timestamp", "startup_timestamp_millis": True}, 
TypeError,
+             "startup_timestamp_millis must be an int"),
+            ({"bounded_mode": "bad"}, ValueError, "bounded_mode"),
+            ({"bounded_mode": "specific-offsets"}, ValueError, 
"bounded_specific_offsets"),
+            ({"bounded_mode": "timestamp"}, ValueError, 
"bounded_timestamp_millis"),
+            ({"bounded_mode": "timestamp", "bounded_timestamp_millis": True}, 
TypeError,
+             "bounded_timestamp_millis must be an int"),
+            ({"topic_partition_discovery_interval": 1}, TypeError,
+             "topic_partition_discovery_interval must be a string"),
+            ({"key_format": 1}, TypeError, "key_format must be a string"),
+            ({"key_fields": "id"}, TypeError, "key_fields must be a list"),
+            ({"key_fields": []}, ValueError, "key_fields must not be empty"),
+            ({"key_fields": [1]}, TypeError, "key_fields elements must be 
strings"),
+            ({"key_fields_prefix": 1}, TypeError, "key_fields_prefix must be a 
string"),
+            ({"properties": {"properties.bootstrap.servers": "other:9092"}}, 
ValueError,
+             "properties.bootstrap.servers"),
+            ({"properties": {"properties.group.id": "other"}}, ValueError,
+             "properties.group.id"),
+        ]
+
+        for overrides, error_type, message in cases:
+            with self.subTest(overrides=overrides):
+                arguments = {"bootstrap_servers": "localhost:9092"}
+                arguments.update(base_arguments)
+                arguments.update(overrides)
+                with self.assertRaisesRegex(error_type, message):
+                    _build_kafka_options(**arguments)
+
+    def test_source_accepts_explicit_value_format_options(self):
+        arguments = {
+            "bootstrap_servers": "localhost:9092",
+            "topic": "events",
+            "topic_pattern": None,
+            "group_id": None,
+            "value_format": "json",
+            "format_options": {"ignore-parse-errors": "true"},
+            "value_format_options": {"ignore-parse-errors": "false"},
+            "key_format": None,
+            "key_format_options": None,
+            "key_fields": None,
+            "key_fields_prefix": None,
+            "value_fields_include": "ALL",
+            "startup_mode": "group-offsets",
+            "startup_specific_offsets": None,
+            "startup_timestamp_millis": None,
+            "topic_partition_discovery_interval": None,
+            "bounded_mode": "unbounded",
+            "bounded_timestamp_millis": None,
+            "bounded_specific_offsets": None,
+            "properties": None,
+        }
+        arguments["format_options"] = {"json.ignore-parse-errors": "true"}
+        arguments["value_format_options"] = {"json.ignore-parse-errors": 
"true"}
+        options = _build_kafka_options(**arguments)
+
+        self.assertEqual(options["value.json.ignore-parse-errors"], "true")
+
+    def test_specific_offsets_are_converted(self):
+        self.assertEqual(_convert_specific_offsets({1: 2}, "offsets"), 
"partition:1,offset:2")
+        self.assertEqual(_convert_specific_offsets("partition:0,offset:1", 
"offsets"),
+                         "partition:0,offset:1")
+        self.assertEqual(
+            _convert_specific_offsets({0: 10, 1: 20}, "offsets"),
+            "partition:0,offset:10;partition:1,offset:20")
+        self.assertIsNone(_convert_specific_offsets(None, "offsets"))
+        with self.assertRaisesRegex(TypeError, "offsets keys"):
+            _convert_specific_offsets({"1": 2}, "offsets")
+        with self.assertRaisesRegex(TypeError, "offsets values"):
+            _convert_specific_offsets({1: "2"}, "offsets")
+        with self.assertRaisesRegex(TypeError, "offsets must be"):
+            _convert_specific_offsets([], "offsets")
+
+    def test_source_prefixes_kafka_properties(self):
+        options = _build_kafka_options(
+            "localhost:9092",
+            topic="events",
+            topic_pattern=None,
+            group_id=None,
+            value_format="json",
+            format_options=None,
+            value_format_options=None,
+            key_format=None,
+            key_format_options=None,
+            key_fields=None,
+            key_fields_prefix=None,
+            value_fields_include="ALL",
+            startup_mode="group-offsets",
+            startup_specific_offsets=None,
+            startup_timestamp_millis=None,
+            topic_partition_discovery_interval=None,
+            bounded_mode="unbounded",
+            bounded_timestamp_millis=None,
+            bounded_specific_offsets=None,
+            properties={"security.protocol": "SASL_SSL"},
+        )
+
+        self.assertEqual(options["properties.security.protocol"], "SASL_SSL")
+
+    def test_builds_sink_options(self):
+        options = _build_kafka_sink_options(
+            "localhost:9092",
+            topic="events",
+            value_format="json",
+            format="json",
+            format_options={"ignore-parse-errors": "true"},
+            value_format_options=None,
+            key_format="json",
+            key_format_options={"ignore-parse-errors": "false"},
+            key_fields=["id", "name"],
+            value_fields_include="EXCEPT_KEY",
+            delivery_guarantee="exactly-once",
+            transactional_id_prefix="events-writer",
+            properties={"transaction.timeout.ms": "60000"},
+            parallelism=3,
+        )
+
+        self.assertEqual(options, {
+            "properties.bootstrap.servers": "localhost:9092",
+            "topic": "events",
+            "value.format": "json",
+            "value.json.ignore-parse-errors": "true",
+            "key.format": "json",
+            "key.json.ignore-parse-errors": "false",
+            "key.fields": "id;name",
+            "value.fields-include": "EXCEPT_KEY",
+            "sink.delivery-guarantee": "exactly-once",
+            "sink.transactional-id-prefix": "events-writer",
+            "properties.transaction.timeout.ms": "60000",
+            "sink.parallelism": "3",
+        })
+
+    def test_sink_value_format_takes_precedence(self):
+        options = _build_kafka_sink_options(
+            "localhost:9092",
+            topic="events",
+            value_format="csv",
+            format=None,
+            format_options=None,
+            value_format_options=None,
+            key_format=None,
+            key_format_options=None,
+            key_fields=None,
+            value_fields_include="ALL",
+            delivery_guarantee="at-least-once",
+            properties=None,
+            parallelism=None,
+        )
+
+        self.assertEqual(options["value.format"], "csv")
+
+    def test_sink_value_format_overrides_format(self):
+        options = _build_kafka_sink_options(
+            "localhost:9092",
+            topic="events",
+            value_format="csv",
+            format="json",
+            format_options=None,
+            value_format_options=None,
+            key_format=None,
+            key_format_options=None,
+            key_fields=None,
+            value_fields_include="ALL",
+            delivery_guarantee="at-least-once",
+            properties=None,
+            parallelism=None,
+        )
+
+        self.assertEqual(options["value.format"], "csv")
+
+    def test_sink_rejects_invalid_arguments(self):
+        base_arguments = {
+            "value_format": "json",
+            "format": "json",
+            "format_options": None,
+            "value_format_options": None,
+            "key_format": None,
+            "key_format_options": None,
+            "key_fields": None,
+            "value_fields_include": "ALL",
+            "delivery_guarantee": "at-least-once",
+            "properties": None,
+            "parallelism": None,
+        }
+        cases = [
+            ({"bootstrap_servers": None}, TypeError, "bootstrap_servers must 
be a string"),
+            ({"topic": None}, TypeError, "topic must be a string"),
+            ({"topic": ""}, ValueError, "topic must not be empty"),
+            ({"value_fields_include": "BAD"}, ValueError, 
"value_fields_include"),
+            ({"delivery_guarantee": "bad"}, ValueError, "delivery_guarantee"),
+            ({"key_format": 1}, TypeError, "key_format must be a string"),
+            ({"key_fields": "id"}, TypeError, "key_fields must be a list"),
+            ({"key_fields": []}, ValueError, "key_fields must not be empty"),
+            ({"key_fields": [1]}, TypeError, "key_fields elements must be 
strings"),
+            ({"parallelism": "3"}, TypeError, "parallelism must be an int"),
+            ({"parallelism": True}, TypeError, "parallelism must be an int"),
+            ({"properties": {"properties.bootstrap.servers": "other:9092"}}, 
ValueError,
+             "properties.bootstrap.servers"),
+
+        ]
+
+        for overrides, error_type, message in cases:
+            with self.subTest(overrides=overrides):
+                arguments = {
+                    "bootstrap_servers": "localhost:9092",
+                    "topic": "events",
+                }
+                arguments.update(base_arguments)
+                arguments.update(overrides)
+                with self.assertRaisesRegex(error_type, message):
+                    _build_kafka_sink_options(**arguments)
+
+
+class KafkaDescriptorTests(PyFlinkDataFrameUTTestCase):
+    _SCHEMA = {"id": DataType.int64(), "name": DataType.string()}
+
+    def test_read_kafka_builds_source_descriptor(self):
+        with patch.object(
+            self.t_env,
+            "from_descriptor",
+            wraps=self.t_env.from_descriptor,
+        ) as from_descriptor:
+            dataframe = pf.read_kafka(
+                "localhost:9092",
+                topic="events",
+                schema={
+                    "id": DataType.int64(),
+                    "ts_millis": DataType.int64(),
+                },
+                format="json",
+                startup_mode="earliest-offset",
+                computed_columns={
+                    "event_time": "TO_TIMESTAMP_LTZ(ts_millis, 3)"
+                },
+                watermark=(
+                    "event_time",
+                    "event_time - INTERVAL '5' SECOND",
+                ),
+            )
+
+        descriptor = from_descriptor.call_args.args[0]
+        self.assertEqual(descriptor.get_options().get("connector"), "kafka")
+        self.assertEqual(descriptor.get_options().get("topic"), "events")
+        self.assertEqual(descriptor.get_options().get("value.format"), "json")
+        self.assertEqual(
+            descriptor.get_options().get("scan.startup.mode"), 
"earliest-offset")
+        self.assert_dataframe_schema(dataframe, ["id", "ts_millis", 
"event_time"])
+        watermark_specs = 
dataframe._table.get_resolved_schema().get_watermark_specs()
+        self.assertEqual(len(watermark_specs), 1)
+        self.assertEqual(watermark_specs[0].get_rowtime_attribute(), 
"event_time")
+
+    def test_read_kafka_uses_value_format(self):
+        with patch.object(
+            self.t_env,
+            "from_descriptor",
+            wraps=self.t_env.from_descriptor,
+        ) as from_descriptor:
+            pf.read_kafka(
+                "localhost:9092",
+                topic="events",
+                schema=self._SCHEMA,
+                format="json",
+                value_format="csv",
+            )
+
+        options = from_descriptor.call_args.args[0].get_options()
+        self.assertEqual(options.get("value.format"), "csv")
+
+    def test_read_kafka_forwards_raw_options(self):
+        with patch.object(
+            self.t_env, "from_descriptor", wraps=self.t_env.from_descriptor,
+        ) as from_descriptor:
+            pf.read_kafka(
+                "localhost:9092", schema=self._SCHEMA, topic=["events-1", 
"events-2"],
+                key_format="json", key_fields=["id", "name"],
+                startup_mode="specific-offsets", startup_specific_offsets={0: 
10, 1: 20},
+                topic_partition_discovery_interval=None, 
options={"scan.parallelism": "3"},
+            )
+
+        options = from_descriptor.call_args.args[0].get_options()
+        self.assertEqual(options.get("topic"), "events-1;events-2")
+        self.assertEqual(options.get("key.fields"), "id;name")
+        self.assertEqual(options.get("scan.startup.specific-offsets"),
+                         "partition:0,offset:10;partition:1,offset:20")
+        
self.assertEqual(options.get("scan.topic-partition-discovery.interval"), "0")
+        self.assertEqual(options.get("scan.parallelism"), "3")
+
+    def test_write_kafka_forwards_transaction_and_raw_options(self):
+        dataframe = pf.from_records([(1, "a")], schema=["id", "name"])
+        for use_raw_options in (False, True):
+            with self.subTest(use_raw_options=use_raw_options):
+                raw_options = {"sink.parallelism": "3"}
+                transaction_options = {
+                    "sink.transactional-id-prefix": "events-writer", 
"sink.partitioner": "fixed",
+                }
+                if use_raw_options:
+                    raw_options.update(transaction_options)
+                with patch.object(dataframe._table, "execute_insert") as 
execute_insert:
+                    dataframe.write_kafka(
+                        "localhost:9092", topic="events", 
delivery_guarantee="exactly-once",
+                        transactional_id_prefix=None if use_raw_options else 
"events-writer",
+                        partitioner=None if use_raw_options else "fixed", 
options=raw_options,
+                        key_format="json", key_fields=["id", "name"],
+                    )
+
+                options = execute_insert.call_args.args[0].get_options()
+                self.assertEqual(options.get("sink.transactional-id-prefix"), 
"events-writer")
+                self.assertEqual(options.get("sink.partitioner"), "fixed")
+                self.assertEqual(options.get("sink.parallelism"), "3")
+                self.assertEqual(options.get("key.fields"), "id;name")
+
+    def test_write_kafka_stages_options_without_execution(self):
+        dataframe = pf.from_records([(1, "a")], schema=["id", "name"])
+        statement_set = pf.create_statement_set()
+        with patch.object(statement_set, "add_insert") as add_insert, \
+                patch.object(statement_set, "execute") as execute, \
+                patch.object(dataframe._table, "execute_insert") as 
execute_insert:
+            dataframe.write_kafka(
+                "localhost:9092", topic="events", 
delivery_guarantee="exactly-once",
+                transactional_id_prefix="events-writer", partitioner="fixed",
+                options={"sink.parallelism": "3"}, statement_set=statement_set,
+            )
+
+        add_insert.assert_called_once()
+        self.assertIs(add_insert.call_args.args[1], dataframe._table)
+        options = add_insert.call_args.args[0].get_options()
+        self.assertEqual(options.get("sink.transactional-id-prefix"), 
"events-writer")
+        self.assertEqual(options.get("sink.partitioner"), "fixed")
+        self.assertEqual(options.get("sink.parallelism"), "3")
+        execute.assert_not_called()
+        execute_insert.assert_not_called()
+
+    def test_write_kafka_builds_sink_descriptor(self):
+        dataframe = pf.from_records([(1, "a")], schema=["id", "name"])
+
+        with patch.object(dataframe._table, "execute_insert") as 
execute_insert:
+            execute_insert.return_value = MagicMock()
+            result = dataframe.write_kafka(
+                "localhost:9092",
+                topic="events",
+                format="json",
+                delivery_guarantee="at-least-once",
+                parallelism=3,
+            )
+
+        descriptor = execute_insert.call_args.args[0]
+        self.assertIsNone(result)
+        self.assertIsNone(descriptor.get_schema())
+        self.assertEqual(descriptor.get_options().get("connector"), "kafka")
+        self.assertEqual(descriptor.get_options().get("topic"), "events")
+        self.assertEqual(descriptor.get_options().get("value.format"), "json")
+        self.assertEqual(
+            descriptor.get_options().get("sink.delivery-guarantee"),
+            "at-least-once",
+        )
+        self.assertEqual(descriptor.get_options().get("sink.parallelism"), "3")
+        self.assertEqual(execute_insert.call_args.kwargs, {"overwrite": False})
+
+    def test_write_kafka_uses_value_format(self):
+        dataframe = pf.from_records([(1, "a")], schema=["id", "name"])
+        with patch.object(dataframe._table, "execute_insert") as 
execute_insert:
+            dataframe.write_kafka(
+                "localhost:9092",
+                topic="events",
+                value_format="csv",
+            )
+
+        options = execute_insert.call_args.args[0].get_options()
+        self.assertEqual(options.get("value.format"), "csv")
+
+
+if __name__ == "__main__":
+    unittest.main()

Reply via email to