| ################################################################################ | |
| # 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 Dict, List, Literal, Optional, Tuple, Union | |
| from pyflink.dataframe.catalog import _validate_name | |
| from pyflink.dataframe.context import get_or_create_table_environment | |
| from pyflink.dataframe.dataframe import DataFrame, _normalize_subset | |
| from pyflink.dataframe.datatype import DataType | |
| from pyflink.dataframe.errors import _raise_as_value_error | |
| 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_kafka", "read_parquet"] | |
| def _build_filesystem_options( | |
| path: str, | |
| file_format: str, | |
| *, | |
| connector_parameters: Optional[Dict[str, Optional[str]]] = None, | |
| format_parameters: Optional[Dict[str, Optional[str]]] = None, | |
| extra_connector_options: Optional[Dict[str, str]] = None, | |
| extra_format_options: Optional[Dict[str, str]] = None, | |
| ) -> Dict[str, str]: | |
| if not isinstance(path, str): | |
| raise TypeError("path must be a string") | |
| if not path: | |
| raise ValueError("path must not be empty") | |
| result = {"path": path, "format": file_format} | |
| if extra_connector_options is not None: | |
| _validate_options(extra_connector_options) | |
| for key in extra_connector_options: | |
| if key in ("path", "format"): | |
| raise ValueError(f"{key!r} must not be specified in connector_options") | |
| if key.startswith(file_format + "."): | |
| raise ValueError(f"format option {key!r} must be specified in format_options") | |
| _merge_options(result, extra_connector_options) | |
| if connector_parameters is not None: | |
| _merge_options(result, { | |
| key: value for key, value in connector_parameters.items() if value is not None | |
| }) | |
| normalized_format_options: Dict[str, str] = {} | |
| if extra_format_options is not None: | |
| _validate_options(extra_format_options) | |
| for key, value in extra_format_options.items(): | |
| option = key if key.startswith(file_format + ".") else file_format + "." + key | |
| if option in normalized_format_options: | |
| raise ValueError(f"duplicate format option: {option!r}") | |
| normalized_format_options[option] = value | |
| if format_parameters is not None: | |
| _merge_options(normalized_format_options, { | |
| file_format + "." + key: value | |
| for key, value in format_parameters.items() if value is not None | |
| }) | |
| _merge_options(result, normalized_format_options) | |
| return result | |
| def _merge_options(target: Dict[str, str], options: Dict[str, str]) -> None: | |
| _validate_options(options) | |
| for key, value in options.items(): | |
| if key in target and target[key] != value: | |
| raise ValueError(f"conflicting values for option {key!r}") | |
| target[key] = value | |
| def _boolean_option(value: Optional[bool], name: str) -> Optional[str]: | |
| if value is None: | |
| return None | |
| if not isinstance(value, bool): | |
| raise TypeError(f"{name} must be a bool or None") | |
| return str(value).lower() | |
| def _parallelism_option(value: Optional[int]) -> Optional[str]: | |
| if value is None: | |
| return None | |
| if isinstance(value, bool) or not isinstance(value, int): | |
| raise TypeError("sink_parallelism must be an int or None") | |
| return str(value) | |
| @PublicEvolving() | |
| def read_parquet( | |
| path: str, | |
| *, | |
| schema: Dict[str, DataType], | |
| partition_by: Optional[Union[str, List[str]]] = None, | |
| monitor_interval: Optional[str] = None, | |
| path_regex_pattern: Optional[str] = None, | |
| utc_timezone: Optional[bool] = None, | |
| connector_options: Optional[Dict[str, str]] = None, | |
| format_options: Optional[Dict[str, str]] = None, | |
| computed_columns: Optional[Dict[str, str]] = None, | |
| watermark: Optional[Tuple[str, str]] = None, | |
| ) -> DataFrame: | |
| """ | |
| Read Parquet files using Flink's filesystem connector. | |
| The filesystem connector and Parquet format must be available to Flink. By default, | |
| the source reads the existing files once. Setting ``monitor_interval`` creates an | |
| unbounded source that discovers new files; it requires streaming execution. Files are | |
| identified by path and processed once, rather than tailed for appended data. | |
| With ``partition_by``, monitoring is limited to the partitions discovered during planning; | |
| new partition directories are not discovered, and an initially empty partitioned source | |
| remains empty. | |
| The current filesystem connector cannot read default partitions representing null or empty | |
| partition values. | |
| :param path: File or directory URI supported by Flink's filesystem implementations. | |
| :param schema: Non-empty mapping of physical column names to DataFrame data types. | |
| :param partition_by: Partition column name or non-empty list of names in directory order. | |
| These columns must be declared in ``schema`` and are read from the partition paths. | |
| :param monitor_interval: Positive file discovery interval, for example ``"60s"``. | |
| If neither this parameter nor ``source.monitor-interval`` in ``connector_options`` | |
| is set, the source performs a bounded scan. | |
| :param path_regex_pattern: Java regular expression matched against the entire file path | |
| (excluding the URI scheme and authority), for example ``".*[.]parquet"``. This is not | |
| a substring or filename-only match. Hidden files are excluded. | |
| :param utc_timezone: Use UTC for Parquet timestamp conversion. The format default is | |
| currently ``False``, which uses the JVM default time zone, independently of the | |
| session time zone. | |
| :param connector_options: Additional filesystem options with string keys and values. | |
| The ``connector``, ``path`` and ``format`` keys are reserved. Format options belong in | |
| ``format_options``. Explicit parameter and dictionary values must agree when both are set. | |
| :param format_options: Parquet options with string values, with or without the ``parquet.`` | |
| prefix. Duplicate normalized keys are rejected. ``None`` parameters leave dictionary | |
| values unchanged; conflicting explicit values are rejected. Unspecified options use | |
| the connector or format factory defaults. | |
| :param computed_columns: Optional SQL expressions keyed by computed column name. | |
| :param watermark: Optional ``(column, expression)`` watermark declaration. The column must | |
| have a TIMESTAMP or TIMESTAMP_LTZ type with precision from 0 to 3. | |
| :return: A DataFrame backed by the Parquet source. | |
| :raises TypeError: If an argument has an invalid type. | |
| :raises ValueError: If the path or schema is empty, the partition specification is | |
| empty or contains empty or duplicate names, or options conflict or contain | |
| reserved, empty or duplicate keys. | |
| Example:: | |
| >>> import pyflink.dataframe as pf | |
| >>> events = pf.read_parquet( | |
| ... "file:///tmp/events", | |
| ... schema={"id": pf.DataType.int64(), "name": pf.DataType.string()}, | |
| ... ) | |
| .. versionadded:: 2.4.0 | |
| """ | |
| options = _build_filesystem_options( | |
| path, | |
| "parquet", | |
| connector_parameters={ | |
| "source.monitor-interval": monitor_interval, | |
| "source.path.regex-pattern": path_regex_pattern, | |
| }, | |
| format_parameters={"utc-timezone": _boolean_option(utc_timezone, "utc_timezone")}, | |
| extra_connector_options=connector_options, | |
| extra_format_options=format_options, | |
| ) | |
| return _read( | |
| "filesystem", schema=schema, options=options, | |
| computed_columns=computed_columns, watermark=watermark, partition_by=partition_by, | |
| ) | |
| @PublicEvolving() | |
| def read_json( | |
| path: str, | |
| *, | |
| schema: Dict[str, DataType], | |
| partition_by: Optional[Union[str, List[str]]] = None, | |
| monitor_interval: Optional[str] = None, | |
| path_regex_pattern: Optional[str] = None, | |
| ignore_parse_errors: Optional[bool] = None, | |
| fail_on_missing_field: Optional[bool] = None, | |
| timestamp_format: Optional[str] = None, | |
| connector_options: Optional[Dict[str, str]] = None, | |
| format_options: Optional[Dict[str, str]] = None, | |
| computed_columns: Optional[Dict[str, str]] = None, | |
| watermark: Optional[Tuple[str, str]] = None, | |
| ) -> DataFrame: | |
| """ | |
| Read newline-delimited JSON files using Flink's filesystem connector. | |
| The filesystem connector and JSON format must be available to Flink. By default, | |
| the source reads the existing files once. Setting ``monitor_interval`` creates an | |
| unbounded source that discovers new files; it requires streaming execution. Files are | |
| identified by path and processed once, rather than tailed for appended data. | |
| With ``partition_by``, monitoring is limited to the partitions discovered during planning; | |
| new partition directories are not discovered, and an initially empty partitioned source | |
| remains empty. | |
| The current filesystem connector cannot read default partitions representing null or empty | |
| partition values. | |
| :param path: File or directory URI supported by Flink's filesystem implementations. | |
| :param schema: Non-empty mapping of physical column names to DataFrame data types. | |
| :param partition_by: Partition column name or non-empty list of names in directory order. | |
| These columns must be declared in ``schema`` and are read from the partition paths. | |
| :param monitor_interval: Positive file discovery interval, for example ``"60s"``. | |
| If neither this parameter nor ``source.monitor-interval`` in ``connector_options`` | |
| is set, the source performs a bounded scan. | |
| :param path_regex_pattern: Java regular expression matched against the entire file path | |
| (excluding the URI scheme and authority), for example ``".*[.]json"``. This is not | |
| a substring or filename-only match. Hidden files are excluded. | |
| :param ignore_parse_errors: Skip malformed fields or rows instead of failing. Invalid fields | |
| are set to null where possible. The format default is currently ``False``. | |
| :param fail_on_missing_field: Fail on missing JSON fields. The format default is currently | |
| ``False``. Cannot be enabled together with ``ignore_parse_errors``. | |
| :param timestamp_format: Timestamp representation, ``"SQL"`` or ``"ISO-8601"``. | |
| The format default is currently ``"SQL"``. | |
| :param connector_options: Additional filesystem options with string keys and values. | |
| The ``connector``, ``path`` and ``format`` keys are reserved. Format options belong in | |
| ``format_options``. Explicit parameter and dictionary values must agree when both are set. | |
| :param format_options: JSON format options with string values. Keys may include or omit | |
| the ``json.`` prefix, for example ``{"ignore-parse-errors": "true"}``. ``None`` parameters | |
| leave dictionary values unchanged; conflicting explicit values are rejected. Unspecified | |
| options use the connector or format factory defaults. | |
| :param computed_columns: Optional SQL expressions keyed by computed column name. | |
| :param watermark: Optional ``(column, expression)`` watermark declaration. The column must | |
| have a TIMESTAMP or TIMESTAMP_LTZ type with precision from 0 to 3. | |
| :return: A DataFrame backed by the JSON source. | |
| :raises TypeError: If an argument has an invalid type. | |
| :raises ValueError: If the path or schema is empty, the partition specification is | |
| empty or contains empty or duplicate names, or options conflict or contain | |
| reserved, empty or duplicate keys. | |
| Example:: | |
| >>> import pyflink.dataframe as pf | |
| >>> events = pf.read_json( | |
| ... "file:///tmp/events.json", | |
| ... schema={"id": pf.DataType.int64()}, | |
| ... format_options={"ignore-parse-errors": "true"}, | |
| ... ) | |
| .. versionadded:: 2.4.0 | |
| """ | |
| options = _build_filesystem_options( | |
| path, | |
| "json", | |
| connector_parameters={ | |
| "source.monitor-interval": monitor_interval, | |
| "source.path.regex-pattern": path_regex_pattern, | |
| }, | |
| format_parameters={ | |
| "ignore-parse-errors": _boolean_option(ignore_parse_errors, "ignore_parse_errors"), | |
| "fail-on-missing-field": _boolean_option( | |
| fail_on_missing_field, "fail_on_missing_field"), | |
| "timestamp-format.standard": timestamp_format, | |
| }, | |
| extra_connector_options=connector_options, | |
| extra_format_options=format_options, | |
| ) | |
| return _read( | |
| "filesystem", schema=schema, options=options, | |
| computed_columns=computed_columns, watermark=watermark, partition_by=partition_by, | |
| ) | |
| 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") | |
| if not connector: | |
| raise ValueError("connector must not be empty") | |
| def _validate_options(options: Dict[str, str]) -> None: | |
| if not isinstance(options, dict): | |
| raise TypeError("options must be a dict of string keys and values") | |
| for key, value in options.items(): | |
| if not isinstance(key, str): | |
| raise TypeError("option keys must be strings") | |
| if not key: | |
| raise ValueError("option keys must not be empty") | |
| if key == "connector": | |
| raise ValueError( | |
| "connector must be specified with the connector argument, not in options" | |
| ) | |
| if not isinstance(value, str): | |
| raise TypeError(f"option {key!r} must have a string value") | |
| def _validate_computed_columns(computed_columns: Optional[Dict[str, str]]) -> Dict[str, str]: | |
| if computed_columns is None: | |
| return {} | |
| if not isinstance(computed_columns, dict): | |
| raise TypeError("computed_columns must be a dict of string keys and values") | |
| validated_columns: Dict[str, str] = {} | |
| for name, expression in computed_columns.items(): | |
| if not isinstance(name, str): | |
| raise TypeError("computed column names must be strings") | |
| if not name: | |
| raise ValueError("computed column names must not be empty") | |
| if not isinstance(expression, str): | |
| raise TypeError(f"computed column {name!r} must use a string expression") | |
| if not expression: | |
| raise ValueError( | |
| f"computed column {name!r} expression must not be empty" | |
| ) | |
| validated_columns[name] = expression | |
| return validated_columns | |
| def _validate_watermark( | |
| watermark: Optional[Tuple[str, str]], | |
| ) -> Optional[Tuple[str, str]]: | |
| if watermark is None: | |
| return None | |
| if not isinstance(watermark, tuple) or len(watermark) != 2: | |
| raise TypeError("watermark must be a tuple of (column, expression)") | |
| if any(not isinstance(value, str) for value in watermark): | |
| raise TypeError("watermark column and expression must be strings") | |
| if any(not value for value in watermark): | |
| raise ValueError("watermark column and expression must not be empty") | |
| return watermark | |
| def _build_source_schema( | |
| schema: Dict[str, DataType], | |
| computed_columns: Optional[Dict[str, str]], | |
| watermark: Optional[Tuple[str, str]], | |
| ) -> Schema: | |
| if not isinstance(schema, dict): | |
| raise TypeError("schema must be a dict of column names and DataType values") | |
| if not schema: | |
| raise ValueError("schema must not be empty") | |
| schema_builder = Schema.new_builder() | |
| for name, data_type in schema.items(): | |
| if not isinstance(name, str): | |
| raise TypeError("schema column names must be strings") | |
| if not name: | |
| raise ValueError("schema column names must not be empty") | |
| if not isinstance(data_type, DataType): | |
| raise TypeError(f"schema column {name!r} must use a DataType value") | |
| schema_builder.column(name, data_type._to_table_data_type()) | |
| for name, expression in _validate_computed_columns(computed_columns).items(): | |
| schema_builder.column_by_expression(name, expression) | |
| validated_watermark = _validate_watermark(watermark) | |
| if validated_watermark is not None: | |
| schema_builder.watermark(*validated_watermark) | |
| return schema_builder.build() | |
| def _build_generic_descriptor( | |
| connector: str, | |
| options: Dict[str, str], | |
| schema: Optional[Schema] = None, | |
| partition_by: Optional[Union[str, List[str]]] = None, | |
| ) -> TableDescriptor: | |
| _validate_connector(connector) | |
| _validate_options(options) | |
| partition_keys = _normalize_subset(partition_by, "partition_by") or [] | |
| if any(not key for key in partition_keys): | |
| raise ValueError("partition_by column names must not be empty") | |
| if len(set(partition_keys)) != len(partition_keys): | |
| raise ValueError("partition_by column names must not be duplicated") | |
| descriptor_builder = TableDescriptor.for_connector(connector) | |
| if schema is not None: | |
| descriptor_builder.schema(schema) | |
| if partition_keys: | |
| descriptor_builder.partitioned_by(*partition_keys) | |
| for key, value in options.items(): | |
| descriptor_builder.option(key, value) | |
| return descriptor_builder.build() | |
| def _read( | |
| connector: str, | |
| *, | |
| schema: Dict[str, DataType], | |
| options: Dict[str, str], | |
| computed_columns: Optional[Dict[str, str]], | |
| watermark: Optional[Tuple[str, str]], | |
| partition_by: Optional[Union[str, List[str]]] = None, | |
| ) -> DataFrame: | |
| source_schema = _build_source_schema( | |
| schema, computed_columns=computed_columns, watermark=watermark) | |
| descriptor = _build_generic_descriptor( | |
| connector, options, schema=source_schema, partition_by=partition_by) | |
| table_environment = get_or_create_table_environment() | |
| try: | |
| table = table_environment.from_descriptor(descriptor) | |
| except Exception as error: | |
| _raise_as_value_error(error) | |
| return DataFrame(table) | |
| @PublicEvolving() | |
| def read_generic( | |
| connector: str, | |
| *, | |
| schema: Dict[str, DataType], | |
| options: Dict[str, str], | |
| computed_columns: Optional[Dict[str, str]] = None, | |
| watermark: Optional[Tuple[str, str]] = None, | |
| ) -> DataFrame: | |
| """ | |
| Read data from a connector using its raw Table connector options. | |
| The connector must be available through Flink's factory discovery mechanism. Physical columns | |
| are followed by computed columns in dictionary insertion order. A watermark can reference a | |
| physical or computed timestamp column. | |
| :param connector: Factory identifier used as the ``connector`` Table option. | |
| :param schema: Non-empty mapping of physical column names to DataFrame data types. | |
| :param options: Connector options, excluding the reserved ``connector`` option. | |
| :param computed_columns: Optional SQL expressions keyed by computed column name. | |
| :param watermark: Optional ``(column, expression)`` watermark declaration. | |
| :return: A DataFrame backed by the configured source. | |
| :raises TypeError: If an argument has an invalid type. | |
| :raises ValueError: If a connector, schema, option key, computed column, or watermark value is | |
| empty, or if Flink rejects the schema, such as a computed column that duplicates a | |
| physical column or an invalid computed column or watermark expression. | |
| Example:: | |
| >>> import pyflink.dataframe as pf | |
| >>> events = pf.read_generic( | |
| ... "filesystem", | |
| ... schema={ | |
| ... "id": pf.DataType.int64(), | |
| ... "ts_millis": pf.DataType.int64(), | |
| ... }, | |
| ... options={"path": "file:///tmp/events", "format": "csv"}, | |
| ... computed_columns={ | |
| ... "event_time": "TO_TIMESTAMP_LTZ(ts_millis, 3)" | |
| ... }, | |
| ... watermark=( | |
| ... "event_time", "event_time - INTERVAL '5' SECOND" | |
| ... ), | |
| ... ) | |
| .. versionadded:: 2.4.0 | |
| """ | |
| return _read( | |
| connector, schema=schema, options=options, | |
| computed_columns=computed_columns, watermark=watermark, | |
| ) | |
| def _extend_catalog_table( | |
| table: Table, | |
| computed_columns: Dict[str, str], | |
| watermark: Optional[Tuple[str, str]], | |
| ) -> Table: | |
| """ | |
| Read the catalog table behind ``table`` with extra computed columns and, optionally, a | |
| different watermark. | |
| Flink has no way to add columns to an existing table at read time, so this builds a nameless | |
| copy of the table definition with the new schema and reads from that instead. Everything else | |
| is copied over unchanged: options, partition keys, distribution, snapshot, connection and | |
| comment. The connector sees the same table it always did, only with more columns. The real | |
| catalog table is not touched. | |
| This is the same thing ``TableEnvironment.from(TableDescriptor)`` does in Java. We build the | |
| ``CatalogTable`` ourselves because ``TableDescriptor`` cannot take a distribution or snapshot | |
| object directly. | |
| Tables whose catalog supplies the connector, such as Paimon or Hive, cannot be extended this | |
| way: the copy has no catalog to ask, so it would not know which connector to use. That is why | |
| the ``connector`` option is required. | |
| """ | |
| jvm = get_gateway().jvm | |
| catalog = jvm.org.apache.flink.table.catalog | |
| source = table._j_table.getQueryOperation().getContextResolvedTable() | |
| identifier = source.getIdentifier().asSummaryString() | |
| source_table = source.getTable() | |
| table_kind = source_table.getTableKind() | |
| if table_kind != catalog.CatalogBaseTable.TableKind.TABLE: | |
| raise ValueError( | |
| f"{identifier} is a {table_kind.name().lower()}; computed columns and watermarks can " | |
| f"only be added to tables" | |
| ) | |
| if not source_table.getOptions().get("connector"): | |
| raise ValueError( | |
| f"{identifier} has no 'connector' option, so its connector comes from the catalog and " | |
| f"cannot be reused for an extended copy. Read it without computed_columns and " | |
| f"watermark, or use read_generic." | |
| ) | |
| source_schema = source_table.getUnresolvedSchema() | |
| schema_builder = jvm.org.apache.flink.table.api.Schema.newBuilder() | |
| if watermark is None: | |
| schema_builder.fromSchema(source_schema) | |
| else: | |
| # A schema can only have one watermark. Copy the columns and primary key by hand so the | |
| # table's own watermark is left out, then add the new one below. | |
| schema_builder.fromColumns(source_schema.getColumns()) | |
| primary_key = source_schema.getPrimaryKey() | |
| if primary_key.isPresent(): | |
| schema_builder.primaryKeyNamed( | |
| primary_key.get().getConstraintName(), primary_key.get().getColumnNames() | |
| ) | |
| for name, expression in computed_columns.items(): | |
| schema_builder.columnByExpression(name, expression) | |
| if watermark is not None: | |
| schema_builder.watermark(*watermark) | |
| catalog_table = ( | |
| catalog.CatalogTable.newBuilder() | |
| .schema(schema_builder.build()) | |
| .options(source_table.getOptions()) | |
| .partitionKeys(source_table.getPartitionKeys()) | |
| .distribution(source_table.getDistribution().orElse(None)) | |
| .snapshot(source_table.getSnapshot().orElse(None)) | |
| .connection(source_table.getConnection().orElse(None)) | |
| .comment(source_table.getComment()) | |
| .build() | |
| ) | |
| j_table_environment = table._t_env._j_tenv | |
| try: | |
| resolved_table = j_table_environment.getCatalogManager().resolveCatalogTable(catalog_table) | |
| except Exception as error: | |
| _raise_as_value_error(error) | |
| query_operation = jvm.org.apache.flink.table.operations.SourceQueryOperation( | |
| catalog.ContextResolvedTable.anonymous(resolved_table) | |
| ) | |
| return Table(j_table_environment.createTable(query_operation), table._t_env) | |
| @PublicEvolving() | |
| def read_catalog_table( | |
| path: str, | |
| *, | |
| computed_columns: Optional[Dict[str, str]] = None, | |
| watermark: Optional[Tuple[str, str]] = None, | |
| ) -> DataFrame: | |
| """ | |
| Read a table registered in a catalog. | |
| ``path`` is ``table_name``, ``db_name.table_name``, or ``catalog_name.db_name.table_name``. | |
| Missing parts are resolved against the current catalog and database, see | |
| :func:`~pyflink.dataframe.use_catalog` and :func:`~pyflink.dataframe.use_database`. Names that | |
| are reserved keywords or contain dots must be escaped with backticks. | |
| Use ``computed_columns`` and ``watermark`` to add columns or change the watermark for this read | |
| only. The new columns go after the table's own columns, in the order given. The watermark | |
| replaces whatever the table declares and may use the new columns. Nothing is written back to | |
| the catalog. | |
| This only works for tables that set the ``connector`` option. Tables from catalogs that supply | |
| their own connector, such as Paimon or Hive, can still be read, just without these two | |
| arguments. | |
| :param path: Path of the catalog table. | |
| :param computed_columns: Optional SQL expressions keyed by computed column name. | |
| :param watermark: Optional ``(column, expression)`` watermark declaration. | |
| :return: A DataFrame backed by the catalog table. | |
| :raises TypeError: If ``path`` is not a string or another argument has an invalid type. | |
| :raises ValueError: If ``path`` is empty, malformed, or does not name a table. Also if | |
| ``computed_columns`` or ``watermark`` contain empty strings, are used on a view or on a | |
| table without a ``connector`` option, or if Flink rejects the resulting schema, for example | |
| a duplicate column name or an expression that does not compile. | |
| Example:: | |
| >>> import pyflink.dataframe as pf | |
| >>> pf.create_catalog("my_catalog", {"type": "generic_in_memory"}) | |
| >>> orders = pf.read_catalog_table("my_catalog.default.orders") | |
| >>> pf.use_catalog("my_catalog") | |
| >>> orders = pf.read_catalog_table("default.orders") | |
| >>> pf.use_database("default") | |
| >>> orders = pf.read_catalog_table( | |
| ... "orders", | |
| ... computed_columns={"order_time": "TO_TIMESTAMP_LTZ(ts_millis, 3)"}, | |
| ... watermark=("order_time", "order_time - INTERVAL '5' SECOND"), | |
| ... ) | |
| .. versionadded:: 2.4.0 | |
| """ | |
| _validate_name(path, "path") | |
| table_environment = get_or_create_table_environment() | |
| try: | |
| table = table_environment.from_path(path) | |
| except Exception as error: | |
| _raise_as_value_error(error) | |
| validated_columns = _validate_computed_columns(computed_columns) | |
| validated_watermark = _validate_watermark(watermark) | |
| 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 |