blob: a7265466dd1af693d51c0265d7196c56af6c5785 [file]
################################################################################
# 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 builtins
from enum import Enum
from typing import (
Any,
Collection,
List,
Mapping,
NamedTuple,
Optional,
Sequence,
TYPE_CHECKING,
Tuple,
Union,
cast,
)
if TYPE_CHECKING:
import pandas
import pyarrow
from pyflink.dataframe.context import get_or_create_table_environment
from pyflink.dataframe.dataframe import DataFrame
from pyflink.table import Schema, Table
from pyflink.table.types import (
_create_converter,
_create_type_verifier,
_has_nulltype,
_infer_schema_from_data,
DataTypes,
LocalZonedTimestampType,
RowField,
RowType,
TimestampType,
from_arrow_type,
)
from pyflink.util.api_stability_decorators import PublicEvolving
__all__ = [
"from_arrow",
"from_dict",
"from_pandas",
"from_records",
"from_table",
"range",
]
_SCALAR_SEQUENCE_TYPES = (str, bytes, bytearray, memoryview)
_BIGINT_MIN = -(1 << 63)
_BIGINT_MAX = (1 << 63) - 1
class _WatermarkSpec(NamedTuple):
column: str
expression: str
@classmethod
def parse(cls, watermark: Optional[Tuple[str, str]]) -> Optional["_WatermarkSpec"]:
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) or not value.strip() for value in watermark):
raise TypeError(
"watermark column and expression must be non-empty strings"
)
return cls(*watermark)
def normalize_row_type(self, row_type: RowType) -> RowType:
matching_fields = [
field for field in row_type.fields if field.name == self.column
]
if not matching_fields:
raise ValueError(
f"watermark column {self.column!r} is not present in data"
)
watermark_type = matching_fields[0].data_type
if not isinstance(watermark_type, (TimestampType, LocalZonedTimestampType)):
raise ValueError(
f"watermark column {self.column!r} must have a timestamp type"
)
fields = []
for field in row_type.fields:
data_type = field.data_type
if field.name == self.column and data_type.precision != 3:
data_type = type(data_type)(3, data_type._nullable)
fields.append(RowField(field.name, data_type, field.description))
return RowType(fields, row_type._nullable)
class _RecordType(Enum):
NAMED_TUPLE = "named_tuple"
MAPPING = "mapping"
SEQUENCE = "sequence"
@classmethod
def from_record(cls, record: Any) -> "_RecordType":
if isinstance(record, tuple) and isinstance(
getattr(record, "_fields", None), tuple
):
return cls.NAMED_TUPLE
if isinstance(record, Mapping):
return cls.MAPPING
if isinstance(record, Sequence) and not isinstance(
record, _SCALAR_SEQUENCE_TYPES
):
return cls.SEQUENCE
raise TypeError(
"record must be a mapping or a sequence of values, "
"such as a list or tuple"
)
def validate(self, record: Any) -> None:
try:
record_type = self.from_record(record)
except TypeError:
record_type = None
if record_type is self:
return
# Treat named tuples as tuples when validating sequence records.
if self is _RecordType.SEQUENCE and record_type is _RecordType.NAMED_TUPLE:
return
raise TypeError(f"record must be a {self.value.replace('_', ' ')}")
def field_names(self, record: Any) -> Collection[str]:
if self is _RecordType.NAMED_TUPLE:
return cast(Tuple[str, ...], getattr(record, "_fields"))
if self is _RecordType.MAPPING:
return cast(Mapping[str, Any], record).keys()
raise TypeError("sequence records do not have named fields")
def normalize_record(
self,
record: Any,
schema: List[str],
require_exact_fields: bool,
) -> Tuple[Any, ...]:
if self is _RecordType.SEQUENCE:
row = tuple(record)
if require_exact_fields and len(row) != len(schema):
raise ValueError(
f"record has {len(row)} values but schema has {len(schema)} fields"
)
return row
record_fields = self.field_names(record)
for name in schema:
if name not in record_fields:
raise ValueError(f"record is missing schema field {name!r}")
if require_exact_fields and len(record_fields) != len(schema):
extra_fields = [name for name in record_fields if name not in schema]
raise ValueError(
f"record has fields not present in schema: {extra_fields!r}"
)
if self is _RecordType.MAPPING:
field_values = cast(Mapping[str, Any], record)
return tuple(field_values[name] for name in schema)
return tuple(getattr(record, name) for name in schema)
def _validate_schema(schema: List[str]) -> None:
if not isinstance(schema, list) or any(not isinstance(name, str) for name in schema):
raise TypeError("schema must be a list of strings")
if not schema:
raise ValueError("schema must not be empty")
if any(not name for name in schema):
raise ValueError("schema field names must not be empty")
if len(set(schema)) != len(schema):
raise ValueError("schema field names must be unique")
def _resolve_column_names(
input_names: Sequence[str], schema: Optional[List[str]]
) -> List[str]:
column_names = list(input_names) if schema is None else schema
if (
schema is not None
and isinstance(schema, list)
and len(schema) != len(input_names)
):
raise ValueError(
f"schema has {len(schema)} fields but data has "
f"{len(input_names)} columns"
)
_validate_schema(column_names)
return column_names
def _normalize_pandas_column_name(name: Any) -> str:
"""Normalize a pandas column label to the corresponding Arrow field name."""
if isinstance(name, str):
return name
if isinstance(name, bytes):
return name.decode("utf-8")
if isinstance(name, tuple):
return str(tuple(_normalize_pandas_column_name(value) for value in name))
return str(name)
def _infer_schema_and_create_dataframe(
rows: Sequence[Sequence[Any]],
column_names: List[str],
watermark: Optional[_WatermarkSpec] = None,
) -> DataFrame:
row_type = _infer_schema_from_data(rows, names=column_names)
if watermark is None:
table_schema = None
else:
row_type = watermark.normalize_row_type(row_type)
table_schema = (
Schema.new_builder()
.from_row_data_type(row_type)
.watermark(*watermark)
.build()
)
converter = _create_converter(row_type)
verify_row = _create_type_verifier(row_type)
sql_rows = []
for row in rows:
row = converter(row)
verify_row(row)
sql_rows.append(row_type.to_sql_type(row))
table = get_or_create_table_environment()._from_elements(
sql_rows, row_type, table_schema
)
return DataFrame(table)
@PublicEvolving()
def from_table(table: Table) -> DataFrame:
"""
Create a DataFrame that wraps a PyFlink Table.
:param table: Table to wrap without copying or converting it.
:return: A DataFrame backed by the exact supplied Table.
:raises TypeError: If ``table`` is not a :class:`~pyflink.table.Table`.
Example::
>>> import pyflink.dataframe as pf
>>> table = table_env.from_elements([(1, "Alice")], ["id", "name"])
>>> dataframe = pf.from_table(table)
>>> dataframe.to_table() is table
True
.. versionadded:: 2.4.0
"""
if not isinstance(table, Table):
raise TypeError("table must be a pyflink.table.Table")
return DataFrame(table)
@PublicEvolving()
def from_pandas(
pdf: "pandas.DataFrame",
schema: Optional[List[str]] = None,
watermark: Optional[Tuple[str, str]] = None,
) -> DataFrame:
"""
Create a DataFrame from a pandas DataFrame.
Types are inferred from the Arrow representation of the pandas columns. An explicit ``schema``
renames columns positionally and must contain exactly one unique, non-empty name per input
column. Empty inputs are supported when their pandas dtypes can be converted to Flink types.
Timezone-aware timestamps are represented as ``TIMESTAMP`` in the TableEnvironment's
configured local timezone; timezone-naive timestamps are unchanged.
``watermark`` declares an event-time column and its SQL watermark expression. The selected
column must have a timestamp-compatible type. Its precision is normalized to milliseconds;
values with finer precision are truncated to ``TIMESTAMP(3)``.
:param pdf: pandas DataFrame to convert.
:param schema: Optional list of positional result column names.
:param watermark: Optional ``(column, expression)`` watermark declaration.
:return: A DataFrame containing the pandas rows.
:raises TypeError: If the input, schema, watermark, or inferred types are invalid.
:raises ValueError: If schema width or watermark column requirements are not met.
Example::
>>> import pandas as pd
>>> import pyflink.dataframe as pf
>>> pdf = pd.DataFrame({"identifier": [1, 2], "name": ["Alice", "Bob"]})
>>> dataframe = pf.from_pandas(pdf, schema=["id", "name"])
>>> events = pf.from_pandas(
... pd.DataFrame({"ts": pd.to_datetime(["2026-01-01T00:00:00Z"])}),
... watermark=("ts", "ts - INTERVAL '5' SECOND"),
... )
.. versionadded:: 2.4.0
"""
import pandas as pd
if not isinstance(pdf, pd.DataFrame):
raise TypeError(
f"data must be a pandas.DataFrame, but was {type(pdf).__name__}"
)
import pyarrow as pa
input_names = [_normalize_pandas_column_name(name) for name in pdf.columns]
arrow_pdf = pdf.copy(deep=False)
arrow_pdf.columns = [
f"__pyflink_dataframe_column_{index}"
for index in builtins.range(len(input_names))
]
return from_arrow(
pa.Table.from_pandas(arrow_pdf, preserve_index=False),
schema=input_names if schema is None else schema,
watermark=watermark,
)
@PublicEvolving()
def from_arrow(
table: "pyarrow.Table",
schema: Optional[List[str]] = None,
watermark: Optional[Tuple[str, str]] = None,
) -> DataFrame:
"""
Create a DataFrame from a PyArrow Table without converting through pandas.
An explicit ``schema`` renames columns positionally and must contain exactly one unique,
non-empty name per input column. Empty tables are supported when their Arrow field types can be
converted to Flink types.
``watermark`` declares an event-time column and its SQL watermark expression. The selected
column must have a timestamp-compatible type. Its precision is normalized to milliseconds;
values with finer precision are truncated to ``TIMESTAMP(3)``.
:param table: PyArrow Table to convert.
:param schema: Optional list of positional result column names.
:param watermark: Optional ``(column, expression)`` watermark declaration.
:return: A DataFrame containing the Arrow rows.
:raises TypeError: If the input, schema, watermark, or inferred types are invalid.
:raises ValueError: If schema width or watermark column requirements are not met.
Example::
>>> import pyarrow as pa
>>> import pyflink.dataframe as pf
>>> table = pa.table({"id": [1, 2], "name": ["Alice", "Bob"]})
>>> dataframe = pf.from_arrow(table)
>>> events = pf.from_arrow(
... pa.table({"ts": pa.array([0], type=pa.timestamp("ms"))}),
... watermark=("ts", "ts - INTERVAL '5' SECOND"),
... )
.. versionadded:: 2.4.0
"""
import pyarrow as pa
if not isinstance(table, pa.Table):
raise TypeError(
f"data must be a pyarrow.Table, but was {type(table).__name__}"
)
watermark_spec = _WatermarkSpec.parse(watermark)
names = _resolve_column_names(table.column_names, schema)
row_type = RowType(
[
RowField(
name,
from_arrow_type(field.type, field.nullable),
)
for name, field in zip(names, table.schema)
]
)
null_field_names = [
field.name for field in row_type.fields if _has_nulltype(field.data_type)
]
if null_field_names:
columns = ", ".join(repr(name) for name in null_field_names)
raise TypeError(
f"Cannot infer Flink data types for columns with Arrow null types: {columns}. "
"Use explicit pandas or Arrow dtypes for these columns."
)
if watermark_spec is None:
table_schema = None
else:
row_type = watermark_spec.normalize_row_type(row_type)
table_schema = (
Schema.new_builder()
.from_row_data_type(row_type)
.watermark(*watermark_spec)
.build()
)
result = get_or_create_table_environment()._from_arrow(
table, row_type, table_schema
)
return DataFrame(result)
@PublicEvolving()
def from_records(
data: Sequence[Union[Sequence[Any], Mapping[str, Any]]],
schema: Optional[List[str]] = None,
watermark: Optional[Tuple[str, str]] = None,
) -> DataFrame:
"""
Create a DataFrame from row-oriented records.
For mapping and named tuple records with an explicit ``schema``, every record must contain all
schema fields; other fields are ignored. When ``schema`` is omitted, the keys or fields from
the first record are used as the schema and every record must have exactly those fields.
For other sequence records, every record must have the same number of values. A ``schema`` is
required to provide the field names.
Field types are inferred from the record values.
``watermark`` declares an event-time column and its SQL watermark expression. The selected
column must have a timestamp-compatible type. Its precision is normalized to milliseconds;
values with finer precision are truncated to ``TIMESTAMP(3)`` or ``TIMESTAMP_LTZ(3)``.
:param data: Non-empty sequence of mapping or sequence records.
:param schema: Optional non-empty list of field names.
:param watermark: Optional ``(column, expression)`` watermark declaration.
:return: A DataFrame containing the records.
:raises TypeError: If a record or schema has an invalid type.
:raises ValueError: If data or schema is empty, schema field names are invalid, a required
schema is omitted, a required field is absent, inferred record fields differ, or record
widths differ.
Example::
>>> import pyflink.dataframe as pf
>>> users = pf.from_records([
... {"id": 1, "name": "Alice"},
... {"id": 2, "name": "Bob"},
... ])
>>> users = pf.from_records(
... [(1, "Alice"), (2, "Bob")], schema=["id", "name"]
... )
>>> from typing import NamedTuple
>>> class User(NamedTuple):
... id: int
... name: str
>>> users = pf.from_records([User(1, "Alice"), User(2, "Bob")])
>>> selected_users = pf.from_records(
... [User(1, "Alice")], schema=["name", "id"]
... )
>>> from datetime import datetime
>>> events = pf.from_records(
... [{"id": 1, "ts": datetime(2026, 1, 1)}],
... watermark=("ts", "ts - INTERVAL '5' SECOND"),
... )
.. versionadded:: 2.4.0
"""
if not isinstance(data, Sequence) or isinstance(data, _SCALAR_SEQUENCE_TYPES):
raise TypeError(
"data must be a sequence of records, such as a list or tuple"
)
if not data:
raise ValueError("data must not be empty")
watermark_spec = _WatermarkSpec.parse(watermark)
first_record = data[0]
try:
expected_record_type = _RecordType.from_record(first_record)
except TypeError as error:
raise TypeError("invalid record at index 0") from error
if expected_record_type is _RecordType.SEQUENCE:
if schema is None:
raise ValueError("schema is required for sequence records")
require_exact_fields = True
elif schema is None:
schema = list(expected_record_type.field_names(first_record))
require_exact_fields = True
else:
require_exact_fields = False
_validate_schema(schema)
rows: List[Sequence[Any]] = []
for index, record in enumerate(data):
try:
if index > 0:
expected_record_type.validate(record)
row = expected_record_type.normalize_record(
record, schema, require_exact_fields
)
except TypeError as error:
raise TypeError(f"invalid record at index {index}") from error
except ValueError as error:
raise ValueError(f"invalid record at index {index}") from error
rows.append(row)
return _infer_schema_and_create_dataframe(rows, schema, watermark_spec)
@PublicEvolving()
def from_dict(
data: Mapping[str, Sequence[Any]],
schema: Optional[List[str]] = None,
watermark: Optional[Tuple[str, str]] = None,
) -> DataFrame:
"""
Create a DataFrame from a column-oriented dictionary.
All selected columns must contain the same non-zero number of values. ``schema`` can select a
subset of columns and controls their order. If omitted, dictionary insertion order is used.
``watermark`` declares an event-time column and its SQL watermark expression. The selected
column must have a timestamp-compatible type. Its precision is normalized to milliseconds;
values with finer precision are truncated to ``TIMESTAMP(3)`` or ``TIMESTAMP_LTZ(3)``.
:param data: Non-empty mapping of column names to value sequences.
:param schema: Optional non-empty list of selected column names.
:param watermark: Optional ``(column, expression)`` watermark declaration.
:return: A DataFrame containing the selected columns.
:raises TypeError: If ``data`` is not a mapping, or the selected schema or a selected column
value has an invalid type.
:raises ValueError: If the input is empty, schema field names are invalid, selected column
lengths differ, or a selected column is missing.
Example::
>>> from datetime import datetime
>>> import pyflink.dataframe as pf
>>> users = pf.from_dict(
... {"name": ["Alice", "Bob"], "id": [1, 2]},
... schema=["id", "name"],
... )
>>> events = pf.from_dict(
... {"id": [1], "ts": [datetime(2026, 1, 1)]},
... watermark=("ts", "ts - INTERVAL '5' SECOND"),
... )
.. versionadded:: 2.4.0
"""
if not isinstance(data, Mapping):
raise TypeError("data must be a mapping")
if not data:
raise ValueError("data must not be empty")
watermark_spec = _WatermarkSpec.parse(watermark)
if schema is None:
schema = list(data.keys())
_validate_schema(schema)
for name in schema:
if name not in data:
raise ValueError(f"column {name!r} is not present in data")
values = data[name]
if not isinstance(values, Sequence) or isinstance(
values, _SCALAR_SEQUENCE_TYPES
):
raise TypeError(
f"column {name!r} values must be a sequence, "
"such as a list or tuple"
)
lengths = {name: len(data[name]) for name in schema}
if len(set(lengths.values())) != 1:
raise ValueError("columns must have equal lengths")
row_count = next(iter(lengths.values()))
if row_count == 0:
raise ValueError("data must contain at least one row")
rows = [
tuple(data[name][row_index] for name in schema)
for row_index in builtins.range(row_count)
]
return _infer_schema_and_create_dataframe(rows, schema, watermark_spec)
@PublicEvolving()
def range(start_or_end: int, end: Optional[int] = None, step: int = 1) -> DataFrame:
"""
Create a DataFrame containing an integer range in one ``id`` column.
The arguments follow Python's built-in :func:`range` semantics. The result always has an
``id BIGINT`` column, including when the requested range is empty.
:param start_or_end: End value when ``end`` is omitted, otherwise the start value.
:param end: Optional exclusive end value.
:param step: Distance between adjacent values; must not be zero.
:return: A DataFrame with one ``id`` column.
:raises TypeError: If an argument is not an integer.
:raises ValueError: If ``step`` is zero or the range contains values outside the signed
``BIGINT`` bounds.
Example::
>>> import pyflink.dataframe as pf
>>> identifiers = pf.range(1, 6, 2)
>>> identifiers.collect()
[<Row(1)>, <Row(3)>, <Row(5)>]
.. versionadded:: 2.4.0
"""
if not isinstance(start_or_end, int):
raise TypeError("start_or_end must be an integer")
if end is not None and not isinstance(end, int):
raise TypeError("end must be an integer")
if not isinstance(step, int):
raise TypeError("step must be an integer")
if step == 0:
raise ValueError("step must not be zero")
if end is None:
start = 0
stop = start_or_end
else:
start = start_or_end
stop = end
values = builtins.range(start, stop, step)
has_values = start < stop if step > 0 else start > stop
if has_values and not (
_BIGINT_MIN <= values[0] <= _BIGINT_MAX
and _BIGINT_MIN <= values[-1] <= _BIGINT_MAX
):
raise ValueError("range values must fit in signed BIGINT")
row_type = DataTypes.ROW([DataTypes.FIELD("id", DataTypes.BIGINT())])
sql_rows = [row_type.to_sql_type((value,)) for value in values]
table = get_or_create_table_environment()._from_elements(sql_rows, row_type)
return DataFrame(table)