blob: 27c06be2b15f65a2aad87853296e2b230ab906cc [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.
################################################################################
"""User-defined scalar functions for the DataFrame API."""
import functools
import inspect
from collections.abc import Iterator, Mapping
from dataclasses import dataclass, field
from enum import Enum
from typing import (
Any,
Callable,
Dict,
FrozenSet,
Iterable,
List,
Optional,
Tuple,
Type,
Union,
cast,
get_type_hints,
overload,
)
from pyflink.common import Row
from pyflink.dataframe.datatype import DataType
from pyflink.table.expression import Expression
from pyflink.table.expressions import call as table_call
from pyflink.table.typehints import _is_typed_dict
from pyflink.table.types import ArrayType, MapType, RowType
from pyflink.table.udf import (
AsyncScalarFunction,
ScalarFunction,
UserDefinedFunction,
UserDefinedFunctionWrapper,
UserDefinedAsyncScalarFunctionWrapper,
UserDefinedScalarFunctionWrapper,
_validate_udf_execution_options,
udf as table_udf,
)
from pyflink.util.api_stability_decorators import PublicEvolving
__all__ = ["udf"]
_UDFInput = Union[Callable[..., Any], ScalarFunction, AsyncScalarFunction, Type]
_ActiveUDFSource = Union[Callable[..., Any], ScalarFunction, AsyncScalarFunction]
_DataTypeLike = Union[DataType, Type, str]
_UNRESOLVED_TYPE_HINT = object()
class _UDFUsage(Enum):
EXPRESSION = "expression"
MAP = "map"
MAP_BATCHES = "map_batches"
class _UDFSourceKind(Enum):
"""How a resolved UDF source is initialized and invoked on a worker."""
DIRECT_CALLABLE = "direct_callable"
CALLABLE_INSTANCE = "callable_instance"
CALLABLE_CLASS = "callable_class"
SCALAR_FUNCTION_INSTANCE = "scalar_function_instance"
SCALAR_FUNCTION_CLASS = "scalar_function_class"
@property
def is_scalar_function(self) -> bool:
return self in (
_UDFSourceKind.SCALAR_FUNCTION_INSTANCE,
_UDFSourceKind.SCALAR_FUNCTION_CLASS,
)
@dataclass(frozen=True)
class _UDFDeclarationContext:
"""Client-only metadata used while declaring a UDF."""
annotation_target: Callable[..., Any]
defining_class: Optional[Type]
globalns: Dict[str, Any]
localns: Optional[Dict[str, Any]]
ignored_hint_names: FrozenSet[str]
@dataclass(frozen=True)
class _UDFRuntimeSource:
"""Worker-facing recipe used to initialize a UDF."""
callable_source: _UDFInput
kind: _UDFSourceKind
is_async: bool
@property
def default_name(self) -> str:
return _default_udf_name(self.callable_source)
@property
def constructs_on_worker(self) -> bool:
return self.kind in (
_UDFSourceKind.CALLABLE_CLASS,
_UDFSourceKind.SCALAR_FUNCTION_CLASS,
)
def validate_declared_determinism(self, declared: bool) -> None:
if self.kind is _UDFSourceKind.SCALAR_FUNCTION_INSTANCE:
actual = cast(
Union[ScalarFunction, AsyncScalarFunction], self.callable_source
).is_deterministic()
_validate_determinism_agreement(declared, actual)
def create_worker_udf(self) -> "_WorkerUDF":
source = self.callable_source
if self.constructs_on_worker:
source_class = cast(Type, source)
source = source_class()
if self.kind.is_scalar_function:
if not isinstance(source, (ScalarFunction, AsyncScalarFunction)):
raise TypeError(
f"Scalar UDF class '{source_class.__name__}' constructed an "
f"unsupported object of type '{type(source).__name__}'."
)
elif not callable(source):
raise TypeError(
f"Callable class '{source_class.__name__}' constructed a non-callable "
f"object of type '{type(source).__name__}'."
)
return _WorkerUDF(cast(_ActiveUDFSource, source), self.kind)
@dataclass(frozen=True)
class _ResolvedUDF:
"""A resolved declaration split into client and worker metadata."""
runtime_source: _UDFRuntimeSource
declaration_context: _UDFDeclarationContext
@dataclass
class _WorkerUDF:
"""An initialized UDF owned by one worker adapter lifecycle."""
active_source: _ActiveUDFSource
kind: _UDFSourceKind
_lifecycle_opened: bool = field(default=False, init=False, repr=False)
def validate_deterministic(self, declared: bool) -> None:
if self.kind is _UDFSourceKind.SCALAR_FUNCTION_CLASS:
actual = cast(
Union[ScalarFunction, AsyncScalarFunction], self.active_source
).is_deterministic()
_validate_determinism_agreement(declared, actual)
def open(self, function_context: Any) -> None:
if self.kind.is_scalar_function:
cast(
Union[ScalarFunction, AsyncScalarFunction], self.active_source
).open(function_context)
self._lifecycle_opened = True
@property
def invocation(self) -> Callable[..., Any]:
if self.kind is _UDFSourceKind.DIRECT_CALLABLE:
return cast(Callable[..., Any], self.active_source)
if self.kind.is_scalar_function:
return cast(
Union[ScalarFunction, AsyncScalarFunction], self.active_source
).eval
return cast(Callable[..., Any], getattr(self.active_source, "__call__"))
def close(self) -> None:
try:
if self._lifecycle_opened:
cast(
Union[ScalarFunction, AsyncScalarFunction], self.active_source
).close()
finally:
self._lifecycle_opened = False
class _DataFrameUDFWrapper:
"""Internal callable binding a DataFrame scalar UDF to Table expressions."""
_runtime_source: _UDFRuntimeSource
_return_dtype: DataType
_deterministic: bool
_func_type: str
_concurrency: Optional[int]
_batch_size: Optional[int]
_cached_table_udf_wrapper: Optional[UserDefinedFunctionWrapper]
_frozen: bool
__name__: str
def __init__(
self,
runtime_source: _UDFRuntimeSource,
return_dtype: DataType,
deterministic: bool,
name: str,
func_type: str,
concurrency: Optional[int],
batch_size: Optional[int],
) -> None:
object.__setattr__(self, "_runtime_source", runtime_source)
object.__setattr__(self, "_return_dtype", return_dtype)
object.__setattr__(self, "_deterministic", deterministic)
object.__setattr__(self, "_func_type", func_type)
object.__setattr__(self, "_concurrency", concurrency)
object.__setattr__(self, "_batch_size", batch_size)
object.__setattr__(self, "_cached_table_udf_wrapper", None)
declaration_metadata = _unwrap_partial(runtime_source.callable_source)
for attribute_name in ("__module__", "__qualname__", "__doc__"):
try:
attribute_value = getattr(declaration_metadata, attribute_name)
except AttributeError:
continue
object.__setattr__(self, attribute_name, attribute_value)
object.__setattr__(self, "__name__", name)
object.__setattr__(self, "_frozen", True)
def __setattr__(self, name: str, value: Any) -> None:
if getattr(self, "_frozen", False):
raise AttributeError("DataFrame UDF declarations are immutable.")
object.__setattr__(self, name, value)
def __call__(self, *args: Any) -> Expression:
return table_call(self._table_udf_wrapper, *args)
@property
def _table_udf_wrapper(self) -> UserDefinedFunctionWrapper:
if self._cached_table_udf_wrapper is None:
object.__setattr__(
self,
"_cached_table_udf_wrapper",
self._create_table_udf_wrapper(_UDFUsage.EXPRESSION),
)
return cast(UserDefinedFunctionWrapper, self._cached_table_udf_wrapper)
def _create_table_udf_wrapper(
self, usage: _UDFUsage, input_type: Optional[RowType] = None
) -> UserDefinedFunctionWrapper:
adapter_type = (
_DataFrameAsyncScalarFunctionAdapter
if self._runtime_source.is_async
else _DataFrameScalarFunctionAdapter
)
actual_func = cast(
Union[ScalarFunction, AsyncScalarFunction],
adapter_type(
self._runtime_source,
self._return_dtype,
self._deterministic,
usage,
self._func_type,
input_type,
),
)
wrapper = cast(
UserDefinedFunctionWrapper,
table_udf(
actual_func,
result_type=self._return_dtype._to_table_data_type(),
deterministic=self._deterministic,
name=self.__name__,
func_type=self._func_type,
concurrency=self._concurrency,
batch_size=self._batch_size,
),
)
wrapper._returns_table_row = usage in (_UDFUsage.MAP, _UDFUsage.MAP_BATCHES)
return wrapper
@property
def return_dtype(self) -> DataType:
return self._return_dtype
def _resolve_map_udf(
func: Union[_UDFInput, _DataFrameUDFWrapper],
return_dtype: Optional[_DataTypeLike],
func_type: str,
input_type: RowType,
) -> Union[UserDefinedScalarFunctionWrapper, UserDefinedAsyncScalarFunctionWrapper]:
if isinstance(func, _DataFrameUDFWrapper):
if return_dtype is not None:
raise ValueError("return_dtype must not be specified for an existing DataFrame UDF.")
declaration = func
else:
if (inspect.isclass(func) and issubclass(func, (ScalarFunction, AsyncScalarFunction))
and inspect.getattr_static(func, "is_deterministic")
is not inspect.getattr_static(UserDefinedFunction, "is_deterministic")):
raise TypeError(
f"Cannot infer determinism from class {func.__name__!r}. "
"Declare it with pf.udf(..., deterministic=...), or pass an instance."
)
deterministic = (
func.is_deterministic()
if isinstance(func, (ScalarFunction, AsyncScalarFunction))
else True
)
declaration = cast(_DataFrameUDFWrapper, udf(
func, return_dtype=return_dtype, func_type=func_type, deterministic=deterministic
))
if declaration._func_type != func_type:
raise ValueError(
f"The declared UDF mode {declaration._func_type!r} does not match "
f"the required mode {func_type!r}."
)
output_type = declaration.return_dtype._to_table_data_type()
if not isinstance(output_type, RowType):
raise TypeError("return_dtype for map and map_batches must be a struct type.")
usage = _UDFUsage.MAP if func_type == "general" else _UDFUsage.MAP_BATCHES
if usage is _UDFUsage.MAP_BATCHES:
if not input_type.fields:
raise ValueError("map_batches requires at least one input column.")
if func_type == "pandas":
_validate_pandas_map_schema(input_type, output_type)
return cast(
Union[UserDefinedScalarFunctionWrapper, UserDefinedAsyncScalarFunctionWrapper],
declaration._create_table_udf_wrapper(usage, input_type),
)
@overload
def udf(
func: _UDFInput,
*,
return_dtype: Optional[_DataTypeLike] = ...,
deterministic: bool = ...,
name: Optional[str] = ...,
func_type: Optional[str] = ...,
concurrency: Optional[int] = ...,
batch_size: Optional[int] = ...,
) -> Callable[..., Expression]:
...
@overload
def udf(
func: None = ...,
*,
return_dtype: Optional[_DataTypeLike] = ...,
deterministic: bool = ...,
name: Optional[str] = ...,
func_type: Optional[str] = ...,
concurrency: Optional[int] = ...,
batch_size: Optional[int] = ...,
) -> Callable[[_UDFInput], Callable[..., Expression]]:
...
@PublicEvolving()
def udf(
func: Optional[_UDFInput] = None,
*,
return_dtype: Optional[_DataTypeLike] = None,
deterministic: bool = True,
name: Optional[str] = None,
func_type: Optional[str] = None,
concurrency: Optional[int] = None,
batch_size: Optional[int] = None,
) -> Union[
Callable[..., Expression],
Callable[[_UDFInput], Callable[..., Expression]],
]:
"""
Create a scalar UDF for DataFrame expressions.
A UDF can be declared with a bare decorator, a configured decorator, or a
direct call. General UDFs may infer ``return_dtype`` from the return
annotation of the function, ``__call__``, or ``eval``. A ``TypedDict``
return annotation becomes a struct column. Inferred return types are
``NOT NULL``. Annotate ``Optional[T]`` (or ``T | None``) to make the result
nullable::
>>> import pyflink.dataframe as pf
>>> @pf.udf
... def add_one(value: int) -> int:
... return value + 1
>>> @pf.udf(return_dtype=str)
... def as_text(value):
... return str(value)
>>> increment = pf.udf(
... lambda value, amount: value + amount,
... return_dtype="BIGINT",
... )
>>> from typing import TypedDict
>>> class LabeledValue(TypedDict):
... value: int
... label: str
>>> @pf.udf
... def describe(value: int) -> LabeledValue:
... return {"value": value, "label": str(value)}
Plain callable classes can be supplied as zero-argument class objects or
as configured instances. Class objects, including their ``__init__``, are
initialized on the TaskManager, so expensive initialization is deferred::
>>> class AddOne:
... def __call__(self, value: int) -> int:
... return value + 1
>>> add_one_from_class = pf.udf(AddOne)
>>> add_one_from_instance = pf.udf(AddOne())
>>> @pf.udf
... class ModelInference:
... def __init__(self):
... self.model = load_model()
... def __call__(self, features: list[float]) -> float:
... return self.model.predict(features)
:class:`~pyflink.table.udf.ScalarFunction` and
:class:`~pyflink.table.udf.AsyncScalarFunction` class objects and instances
are also supported. Their logical result type is inferred from ``eval``
when it is not given explicitly. Class objects are initialized on the
TaskManager, where their ``open`` and ``close`` methods also run::
>>> from pyflink.table.udf import AsyncScalarFunction, ScalarFunction
>>> class AddOneFunction(ScalarFunction):
... def eval(self, value: int) -> int:
... return value + 1
>>> add_one_class = pf.udf(AddOneFunction)
>>> add_one_instance = pf.udf(AddOneFunction())
>>> class AsyncLookup(AsyncScalarFunction):
... async def eval(self, key: int) -> str:
... return await lookup(key)
>>> async_lookup = pf.udf(AsyncLookup)
Plain ``async def`` functions and callable objects with an asynchronous
``__call__`` use general asynchronous execution::
>>> @pf.udf
... async def async_add_one(value: int) -> int:
... return value + 1
Pandas UDFs always require an explicit logical ``return_dtype``. Each
``ROW``-typed argument is received as a ``pandas.DataFrame`` with one column
per field; other arguments are received as ``pandas.Series``. A ``ROW``-typed
result should be returned as a ``pandas.DataFrame``, while other results
should be returned as ``pandas.Series``. Pandas mode can be selected
explicitly, or inferred from a pandas container annotation on any unbound
parameter or the return value::
>>> import pandas as pd
>>> @pf.udf(
... return_dtype=pf.DataType.int64(),
... func_type="pandas",
... batch_size=256,
... )
... def pandas_add_one(values):
... return values + 1
>>> @pf.udf(return_dtype=pf.DataType.int64())
... def inferred_pandas_add_one(values: pd.Series) -> pd.Series:
... return values + 1
Arrow UDFs always require an explicit logical ``return_dtype`` and support
synchronous functions. Each column argument is received as a ``pyarrow.Array``;
a ``ROW``-typed column is received as a ``pyarrow.StructArray`` with one child
array per field. Results should be returned as a ``pyarrow.Array`` or
``pyarrow.ChunkedArray`` of the declared logical type, with the same number of
rows as the input batch. A ``ROW``-typed result uses a ``pyarrow.StructArray``
or a chunked array of structs. Arrow mode can be selected explicitly, or
inferred from an Arrow container annotation on any unbound parameter or the
return value::
>>> import pyarrow as pa
>>> import pyarrow.compute as pc
>>> @pf.udf(return_dtype=pf.DataType.int64(), func_type="arrow")
... def arrow_add_one(values):
... return pc.add(values, 1)
>>> @pf.udf(return_dtype=pf.DataType.string())
... def normalize_name(names: pa.Array) -> pa.Array:
... return pc.utf8_upper(names)
A declared UDF is called with DataFrame expressions or Python literals to
produce a single-column expression::
>>> df = pf.from_records([(1,), (2,)], schema=["value"])
>>> result = df.with_columns(
... next_value=add_one(pf.col("value")),
... incremented=increment(pf.col("value"), 2),
... )
:param func: Function, callable object, scalar UDF instance, or zero-argument
callable/scalar-UDF class.
:param return_dtype: DataFrame logical type, Python type, or SQL type string.
General UDFs may infer it from a return annotation;
pandas and Arrow UDFs require it.
:param deterministic: Whether equal inputs always produce equal results.
Must agree with scalar-function metadata.
:param name: Non-empty function identity used by the Table planner.
:param func_type: ``"general"``, ``"pandas"``, or ``"arrow"``. If omitted,
unbound container annotations select pandas or Arrow mode;
otherwise general mode is used.
:param concurrency: Optional parallelism for the Python operator that executes this UDF.
Only supported for insert-only streaming inputs.
:param batch_size: Optional maximum Arrow batch size for a pandas or Arrow UDF. If compatible
vectorized UDFs are fused, the smallest explicit value is used.
:return: A callable that accepts DataFrame expressions or Python literals and
returns an :class:`~pyflink.table.expression.Expression`, or a decorator
producing such a callable when ``func`` is omitted.
.. versionadded:: 2.4.0
"""
def decorator(f: _UDFInput) -> Callable[..., Expression]:
resolved_udf = _resolve_udf(f)
runtime_source = resolved_udf.runtime_source
declaration_context = resolved_udf.declaration_context
actual_func_type = (
func_type
if func_type is not None
else _detect_func_type(declaration_context)
)
_validate_scalar_udf_options(
actual_func_type,
return_dtype,
runtime_source.is_async,
concurrency,
batch_size,
)
actual_return_dtype = _infer_return_dtype(
declaration_context, return_dtype, runtime_source.default_name
)
if not isinstance(deterministic, bool):
raise TypeError("deterministic must be a bool.")
runtime_source.validate_declared_determinism(deterministic)
actual_name = runtime_source.default_name if name is None else name
if not isinstance(actual_name, str):
raise TypeError("name must be a str or None.")
if not actual_name:
raise ValueError("name must not be empty.")
return _DataFrameUDFWrapper(
runtime_source,
actual_return_dtype,
deterministic,
actual_name,
actual_func_type,
concurrency,
batch_size,
)
return decorator if func is None else decorator(func)
# ======================== Declaration Validation ========================
def _validate_determinism_agreement(declared: bool, actual: bool) -> None:
if declared != actual:
raise ValueError(
f"Inconsistent deterministic: {declared} and {actual}."
)
def _validate_scalar_udf_options(
func_type: str,
return_dtype: Optional[_DataTypeLike],
is_async: bool,
concurrency: Optional[int],
batch_size: Optional[int],
) -> None:
if func_type not in ("general", "pandas", "arrow"):
raise ValueError(
f"The func_type must be one of 'general, pandas, arrow', got {func_type}."
)
if return_dtype is None and func_type in ("pandas", "arrow"):
raise TypeError(
f"return_dtype is required for {func_type} UDFs because {func_type} container "
"annotations do not describe the logical result type."
)
if is_async and func_type in ("pandas", "arrow"):
raise ValueError(
f"Async scalar functions do not support {func_type} func_type. "
"Use func_type='general'."
)
_validate_udf_execution_options(func_type, concurrency, batch_size)
def _validate_pandas_map_schema(input_type: RowType, output_type: RowType) -> None:
from pyflink.table.types import create_arrow_schema
if not output_type.fields:
raise ValueError("Pandas map_batches requires at least one output column.")
# Validate physical columns even when fusion bypasses Arrow transport.
for row_type in (input_type, output_type):
create_arrow_schema(row_type.field_names(), row_type.field_types())
# ======================== Callable Inspection and Resolution ========================
# ---- Invocation target resolution ----
def _unwrap_partial(func: Any) -> Any:
while isinstance(func, functools.partial):
func = func.func
return func
def _default_udf_name(func: _UDFInput) -> str:
target = _unwrap_partial(func)
name = getattr(target, "__name__", None)
return name if isinstance(name, str) else type(target).__name__
def _get_callable_inspection_target(
func: Callable[..., Any],
) -> Callable[..., Any]:
target = _unwrap_partial(func)
if callable(target) and not inspect.isroutine(target) and not inspect.isclass(target):
return cast(Callable[..., Any], getattr(target, "__call__"))
return cast(Callable[..., Any], target)
def _first_parameter_name(func: Callable[..., Any]) -> Optional[str]:
try:
parameters = tuple(
inspect.signature(func, follow_wrapped=False).parameters.values()
)
except (TypeError, ValueError):
return None
return parameters[0].name if parameters else None
def _resolve_class_invocation_target(
func_class: Type, method_name: str
) -> Tuple[Optional[Callable[..., Any]], Optional[Type], Optional[str]]:
"""Resolve the nearest supported invocation method without constructing a class."""
descriptor_owner = None
descriptor = None
for candidate in func_class.__mro__:
if method_name in candidate.__dict__:
descriptor_owner = candidate
descriptor = candidate.__dict__[method_name]
break
if descriptor_owner is None:
return None, None, None
if isinstance(descriptor, staticmethod):
target = descriptor.__func__
implicit_parameter_name = None
elif isinstance(descriptor, classmethod):
target = descriptor.__func__
implicit_parameter_name = (
_first_parameter_name(target) if inspect.isroutine(target) else None
)
elif inspect.isroutine(descriptor):
target = descriptor
implicit_parameter_name = _first_parameter_name(target)
else:
return None, descriptor_owner, None
if not callable(target) or not inspect.isroutine(target):
return None, descriptor_owner, None
return cast(Callable[..., Any], target), descriptor_owner, implicit_parameter_name
def _validate_zero_argument_class(func_class: Type) -> None:
if inspect.isabstract(func_class):
raise TypeError(f"UDF class '{func_class.__name__}' must not be abstract.")
try:
constructor_signature = inspect.signature(func_class)
except (TypeError, ValueError) as exc:
raise TypeError(
f"Cannot verify that UDF class '{func_class.__name__}' has a zero-argument "
"constructor; pass a configured instance instead."
) from exc
try:
constructor_signature.bind()
except TypeError as exc:
raise TypeError(
f"UDF class '{func_class.__name__}' must have a zero-argument constructor; "
"pass a configured instance instead."
) from exc
# ---- Annotation namespace resolution ----
def _function_qualname(func: Callable[..., Any]) -> Optional[str]:
target = _unwrap_partial(func)
target = getattr(target, "__func__", target)
qualname = getattr(target, "__qualname__", None)
return qualname if isinstance(qualname, str) else None
def _lexical_defining_class(
target: Callable[..., Any], candidate: Optional[Type] = None
) -> Optional[Type]:
qualname = _function_qualname(target)
if qualname is None:
return None
owner_qualname, separator, _ = qualname.rpartition(".")
if not separator:
return None
if candidate is not None:
# Same-qualified-name method transplantation is indistinguishable because
# Python functions do not retain an exact defining-class identity.
return candidate if candidate.__qualname__ == owner_qualname else None
bound_target = _unwrap_partial(target)
receiver = getattr(bound_target, "__self__", None)
if receiver is None:
return None
receiver_class = receiver if inspect.isclass(receiver) else type(receiver)
return next(
(
owner
for owner in receiver_class.__mro__
if owner.__qualname__ == owner_qualname
),
None,
)
def _get_callable_globals(func: Callable[..., Any]) -> Dict[str, Any]:
func_globals = getattr(func, "__globals__", None)
if func_globals is None:
func_globals = getattr(
getattr(func, "__func__", None), "__globals__", {}
)
return cast(Dict[str, Any], func_globals)
def _get_annotation_globals(func: Callable[..., Any]) -> Dict[str, Any]:
try:
unwrapped = inspect.unwrap(func)
except ValueError:
return _get_callable_globals(func)
annotations = getattr(func, "__annotations__", None)
if annotations is not None and annotations is getattr(
unwrapped, "__annotations__", None
):
return _get_callable_globals(unwrapped)
return _get_callable_globals(func)
# ---- Signature and declaration context assembly ----
def _preserves_method_binding(
target: Callable[..., Any], defining_class: Optional[Type]
) -> bool:
target = _unwrap_partial(target)
target = getattr(target, "__func__", target)
if defining_class is None or not hasattr(target, "__wrapped__"):
return False
try:
unwrapped_target = inspect.unwrap(target)
except ValueError:
return False
return _lexical_defining_class(
cast(Callable[..., Any], unwrapped_target), defining_class
) is defining_class
def _resolve_ignored_hint_names(
annotation_target: Callable[..., Any],
implicit_parameter_name: Optional[str],
partial_source: Any,
preserves_method_binding: bool,
) -> FrozenSet[str]:
ignored_hint_names = set()
if implicit_parameter_name is not None:
ignored_hint_names.add(implicit_parameter_name)
if not isinstance(partial_source, functools.partial):
return frozenset(ignored_hint_names)
bound_function = getattr(annotation_target, "__func__", None)
is_wrapped_bound_method = bound_function is not None and hasattr(
bound_function, "__wrapped__"
)
uses_unbound_wrapped_signature = (
is_wrapped_bound_method and not preserves_method_binding
)
try:
partial_target_signature = inspect.signature(
cast(Callable[..., Any], bound_function)
if uses_unbound_wrapped_signature
else partial_source.func
)
except Exception:
return frozenset(ignored_hint_names)
try:
bound_arguments = partial_target_signature.bind_partial(
*partial_source.args, **(partial_source.keywords or {})
)
except TypeError as exc:
raise TypeError(
f"Invalid functools.partial UDF "
f"'{_default_udf_name(partial_source)}': {exc}."
) from exc
ignored_hint_names.update(bound_arguments.arguments)
return frozenset(ignored_hint_names)
def _create_declaration_context(
annotation_target: Callable[..., Any],
*,
descriptor_owner: Optional[Type] = None,
implicit_parameter_name: Optional[str] = None,
partial_source: Any = None,
) -> _UDFDeclarationContext:
annotation_target = cast(
Callable[..., Any], _get_callable_inspection_target(annotation_target)
)
defining_class = _lexical_defining_class(
annotation_target, descriptor_owner
)
preserves_method_binding = _preserves_method_binding(
annotation_target, defining_class
)
if preserves_method_binding:
implicit_parameter_name = _first_parameter_name(
cast(Callable[..., Any], inspect.unwrap(annotation_target))
)
if implicit_parameter_name is None:
bound_target = _unwrap_partial(annotation_target)
bound_function = getattr(bound_target, "__func__", None)
if bound_function is not None:
implicit_parameter_name = _first_parameter_name(bound_function)
localns = None
if defining_class is not None:
localns = dict(vars(defining_class))
localns[defining_class.__name__] = defining_class
ignored_hint_names = _resolve_ignored_hint_names(
annotation_target,
implicit_parameter_name,
partial_source,
preserves_method_binding,
)
return _UDFDeclarationContext(
annotation_target=annotation_target,
defining_class=defining_class,
globalns=_get_annotation_globals(annotation_target),
localns=localns,
ignored_hint_names=ignored_hint_names,
)
def _create_resolved_udf(
func: _UDFInput,
kind: _UDFSourceKind,
declaration_context: _UDFDeclarationContext,
*,
async_marker: bool = False,
) -> _ResolvedUDF:
target = declaration_context.annotation_target
target_is_async = inspect.iscoroutinefunction(target)
try:
unwrapped_target = inspect.unwrap(target)
except ValueError as exc:
raise TypeError(
"Cannot inspect a UDF with a wrapper cycle."
) from exc
if not target_is_async and inspect.iscoroutinefunction(unwrapped_target):
raise TypeError(
"A synchronous UDF wrapper cannot wrap an async target; define the "
"wrapper with async def."
)
if async_marker and not target_is_async:
raise TypeError(
f"AsyncScalarFunction '{_default_udf_name(func)}': eval must be "
"defined with async def."
)
is_async = target_is_async
return _ResolvedUDF(
_UDFRuntimeSource(func, kind, is_async), declaration_context
)
def _resolve_udf(func: _UDFInput) -> _ResolvedUDF:
"""Validate a UDF and resolve its declaration and runtime metadata."""
if isinstance(func, functools.partial) or inspect.isroutine(func):
declaration_context = _create_declaration_context(
cast(Callable[..., Any], func),
partial_source=func,
)
return _create_resolved_udf(
func, _UDFSourceKind.DIRECT_CALLABLE, declaration_context
)
if inspect.isclass(func):
if issubclass(func, UserDefinedFunction) and not issubclass(
func, (ScalarFunction, AsyncScalarFunction)
):
raise TypeError(f"func must be a scalar UDF, got {func.__name__}.")
if issubclass(func, (ScalarFunction, AsyncScalarFunction)):
_validate_zero_argument_class(func)
target, descriptor_owner, implicit_parameter_name = (
_resolve_class_invocation_target(func, "eval")
)
if target is None:
raise TypeError(
f"Scalar UDF class '{func.__name__}' has an unsupported eval "
"definition.\nDefine eval as an instance, class, or static method."
)
declaration_context = _create_declaration_context(
target,
descriptor_owner=descriptor_owner,
implicit_parameter_name=implicit_parameter_name,
)
return _create_resolved_udf(
func,
_UDFSourceKind.SCALAR_FUNCTION_CLASS,
declaration_context,
async_marker=issubclass(func, AsyncScalarFunction),
)
target, descriptor_owner, implicit_parameter_name = (
_resolve_class_invocation_target(func, "__call__")
)
if target is None:
if descriptor_owner is None:
raise TypeError(f"func must be callable, got {func.__name__}.")
raise TypeError(
f"Callable class '{func.__name__}' has an unsupported __call__ "
"definition.\nDefine __call__ as an instance, class, or static method."
)
_validate_zero_argument_class(func)
declaration_context = _create_declaration_context(
target,
descriptor_owner=descriptor_owner,
implicit_parameter_name=implicit_parameter_name,
)
return _create_resolved_udf(
func, _UDFSourceKind.CALLABLE_CLASS, declaration_context
)
if isinstance(func, UserDefinedFunction) and not isinstance(
func, (ScalarFunction, AsyncScalarFunction)
):
raise TypeError(f"func must be a scalar UDF, got {type(func).__name__}.")
if isinstance(func, (ScalarFunction, AsyncScalarFunction)):
target = func.eval
if not callable(target):
raise TypeError(
f"Scalar UDF instance '{type(func).__name__}': eval must be callable."
)
declaration_context = _create_declaration_context(
cast(Callable[..., Any], target),
partial_source=target,
)
return _create_resolved_udf(
func,
_UDFSourceKind.SCALAR_FUNCTION_INSTANCE,
declaration_context,
async_marker=isinstance(func, AsyncScalarFunction),
)
if not callable(func):
raise TypeError(f"func must be callable, got {type(func).__name__}.")
target = getattr(func, "__call__")
if not callable(target):
raise TypeError(
f"Callable instance '{type(func).__name__}': __call__ must be callable."
)
declaration_context = _create_declaration_context(
cast(Callable[..., Any], target),
partial_source=target,
)
return _create_resolved_udf(
func, _UDFSourceKind.CALLABLE_INSTANCE, declaration_context
)
# ---- Annotation and type inference ----
def _resolve_callable_annotation(
declaration_context: _UDFDeclarationContext,
annotation_name: str,
globalns: Optional[Dict[str, Any]] = None,
) -> Any:
func = declaration_context.annotation_target
annotations = getattr(func, "__annotations__", {})
if annotation_name not in annotations:
return _UNRESOLVED_TYPE_HINT
def annotation_holder() -> None:
pass
annotation_holder.__annotations__ = {
annotation_name: annotations[annotation_name]
}
try:
return get_type_hints(
annotation_holder,
globalns=(
declaration_context.globalns if globalns is None else globalns
),
localns=declaration_context.localns,
).get(annotation_name, _UNRESOLVED_TYPE_HINT)
except (NameError, AttributeError, SyntaxError, TypeError):
return _UNRESOLVED_TYPE_HINT
def _get_callable_return_type_hint(
declaration_context: _UDFDeclarationContext,
) -> Any:
# Resolve the return annotation in isolation so an unresolvable parameter
# annotation does not prevent return-type inference.
return _resolve_callable_annotation(declaration_context, "return")
def _infer_return_dtype(
declaration_context: _UDFDeclarationContext,
return_dtype: Optional[_DataTypeLike],
udf_name: str,
) -> DataType:
"""Infer the DataFrame return type or validate its explicit declaration."""
if return_dtype is not None:
return _convert_to_dtype(return_dtype)
annotations = getattr(
declaration_context.annotation_target, "__annotations__", {}
) or {}
if "return" not in annotations:
raise TypeError(
f"Cannot infer return_dtype for '{udf_name}': add a return annotation "
"or specify return_dtype explicitly."
)
return_hint = _get_callable_return_type_hint(declaration_context)
if return_hint is _UNRESOLVED_TYPE_HINT:
raise TypeError(
f"Cannot infer return_dtype for '{udf_name}' from its return annotation.\n"
"Specify return_dtype explicitly."
)
try:
return DataType._from_type_hint(return_hint)
except (NameError, AttributeError, SyntaxError, TypeError) as exc:
raise TypeError(
f"Cannot infer return_dtype for '{udf_name}' from its return annotation.\n"
"Specify return_dtype explicitly."
) from exc
def _convert_to_dtype(dtype_like: _DataTypeLike) -> DataType:
if isinstance(dtype_like, DataType):
return dtype_like
if isinstance(dtype_like, str):
return DataType._from_sql(dtype_like)
try:
return DataType._from_type_hint(dtype_like)
except (NameError, AttributeError, SyntaxError, TypeError) as exc:
if _is_typed_dict(dtype_like):
raise TypeError(
"Cannot resolve return_dtype from the supplied TypedDict; use a "
"concrete DataFrame DataType or SQL type string."
) from exc
raise TypeError(
"return_dtype must be a DataFrame DataType, Python type, or SQL "
f"type string, got {type(dtype_like).__name__}."
) from exc
def _detect_func_type(declaration_context: _UDFDeclarationContext) -> str:
"""Detect a unique vectorized mode from unbound container annotations."""
hint_func = declaration_context.annotation_target
container_types: Dict[str, Tuple[Type, ...]] = {}
container_globalns: Dict[str, Any] = {}
try:
import pandas as pd
container_types["pandas"] = (pd.Series, pd.DataFrame)
container_globalns.update(pandas=pd, pd=pd)
except ImportError:
pass
try:
import pyarrow as pa
container_types["arrow"] = (pa.Array, pa.ChunkedArray)
container_globalns.update(pyarrow=pa, pa=pa)
except ImportError:
pass
modes: set[str] = set()
for name in getattr(hint_func, "__annotations__", {}):
if name in declaration_context.ignored_hint_names:
continue
hint = _resolve_callable_annotation(
declaration_context,
name,
globalns={**container_globalns, **declaration_context.globalns},
)
modes.update(
mode for mode, types in container_types.items()
if hint in types or (
mode == "arrow" and isinstance(hint, type) and issubclass(hint, types)
)
)
if len(modes) > 1:
raise ValueError(
"UDF annotations contain both pandas and Arrow containers; "
"specify func_type explicitly."
)
return next(iter(modes), "general")
# ======================== Worker Adapters ========================
def _wrap_scalar_general_result(
func: Callable[..., Any],
result_normalizer: Callable[[Any], Any],
is_async: bool,
) -> Callable[..., Any]:
if is_async:
@functools.wraps(func)
async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
return result_normalizer(await func(*args, **kwargs))
wrapper = async_wrapper
else:
@functools.wraps(func)
def sync_wrapper(*args: Any, **kwargs: Any) -> Any:
return result_normalizer(func(*args, **kwargs))
wrapper = sync_wrapper
if not hasattr(func, "__name__"):
wrapper.__name__ = type(func).__name__
return wrapper
class _DataFrameUDFAdapterBase:
"""Bind a lazy DataFrame UDF source to one worker invocation protocol."""
def __init__(
self,
runtime_source: _UDFRuntimeSource,
return_dtype: DataType,
deterministic: bool,
usage: _UDFUsage,
func_type: str,
input_type: Optional[RowType] = None,
) -> None:
self._runtime_source = runtime_source
self._worker_udf: Optional[_WorkerUDF] = None
self._return_dtype = return_dtype
# MAP and MAP_BATCHES need the input DataFrame schema to build their input dictionaries.
self._input_type = input_type
self._deterministic = deterministic
self._usage = usage
self._func_type = func_type
self._bound_invocation: Optional[Callable[..., Any]] = None
self.__name__ = runtime_source.default_name
self.__doc__ = getattr(runtime_source.callable_source, "__doc__", None)
def open(self, function_context: Any) -> None:
worker_udf = self._runtime_source.create_worker_udf()
try:
worker_udf.validate_deterministic(self._deterministic)
worker_udf.open(function_context)
self._bound_invocation = self._bind_func(worker_udf.invocation)
self._worker_udf = worker_udf
except Exception:
try:
worker_udf.close()
except Exception:
pass
self._bound_invocation = None
self._worker_udf = None
raise
def _bind_func(self, invoke_func: Callable[..., Any]) -> Callable[..., Any]:
if self._usage in (_UDFUsage.MAP, _UDFUsage.MAP_BATCHES):
if self._input_type is None:
raise ValueError("A map binding requires an input schema.")
output_type = cast(RowType, self._return_dtype._to_table_data_type())
if self._usage is _UDFUsage.MAP:
input_names = self._input_type.field_names()
normalize_result = cast(Callable[[Any], Any], _create_result_normalizer(
output_type, require_row_fields=True))
def normalize_map_result(value):
if not isinstance(value, Mapping):
raise TypeError("A map function must return a dictionary.")
return normalize_result(value)
normalized_func = _wrap_scalar_general_result(
invoke_func, normalize_map_result, self._runtime_source.is_async,
)
@functools.wraps(invoke_func)
def map_row(*values):
return normalized_func(dict(zip(input_names, values)))
return map_row
wrap = _wrap_map_pandas if self._func_type == "pandas" else _wrap_map_arrow
return wrap(invoke_func, self._input_type, output_type)
if self._func_type == "general":
result_normalizer = _create_result_normalizer(
self._return_dtype._to_table_data_type()
)
if result_normalizer is None:
return invoke_func
return _wrap_scalar_general_result(
invoke_func,
result_normalizer,
self._runtime_source.is_async,
)
return invoke_func
def close(self) -> None:
worker_udf = self._worker_udf
try:
if worker_udf is not None:
worker_udf.close()
finally:
self._bound_invocation = None
self._worker_udf = None
def is_deterministic(self) -> bool:
return self._deterministic
def _invocation(self) -> Callable[..., Any]:
if self._bound_invocation is None:
raise RuntimeError("DataFrame UDF was invoked before open().")
return self._bound_invocation
class _DataFrameScalarFunctionAdapter(_DataFrameUDFAdapterBase, ScalarFunction):
"""Synchronous terminal adapter for a bound DataFrame UDF."""
def eval(self, *args: Any) -> Any:
invoke_func = self._invocation()
if self._func_type == "pandas" and self._usage is not _UDFUsage.MAP_BATCHES:
from pyflink.fn_execution.utils.operation_utils import (
check_pandas_udf_result,
)
return check_pandas_udf_result(invoke_func, *args)
return invoke_func(*args)
class _DataFrameAsyncScalarFunctionAdapter(
_DataFrameUDFAdapterBase, AsyncScalarFunction
):
"""Asynchronous terminal adapter for a bound DataFrame UDF."""
async def eval(self, *args: Any) -> Any:
return await self._invocation()(*args)
# ======================== Result Normalization ========================
def _row_field_values(
value: Any, field_names: List[str], *, require_row_fields: bool = False,
) -> List[Any]:
if isinstance(value, Mapping):
if require_row_fields:
return [value[field_name] for field_name in field_names]
return [value.get(field_name) for field_name in field_names]
if isinstance(value, Row) and hasattr(value, "_fields"):
field_indices: Dict[str, int] = {}
for index, field_name in enumerate(value._fields):
field_indices.setdefault(field_name, index)
field_values: List[Any] = []
for field_name in field_names:
if field_name not in field_indices:
raise ValueError(
f"Field name {field_name!r} does not exist in Row fields "
f"{value._fields}."
)
field_index = field_indices[field_name]
if field_index >= len(value):
raise ValueError(
f"Field name {field_name!r} is declared in Row fields "
f"{value._fields} but has no value."
)
field_values.append(value[field_index])
return field_values
if isinstance(value, (Row, tuple, list)):
field_count = len(field_names)
if len(value) != field_count:
raise ValueError(
f"Expected {field_count} value(s) for RowType "
f"{field_names}, got {len(value)}."
)
return list(value)
return [
_object_row_field_value(value, field_name, field_names, require_row_fields)
for field_name in field_names
]
def _object_row_field_value(
value: Any, field_name: str, field_names: List[str], require_row_fields: bool
) -> Any:
try:
return getattr(value, field_name)
except AttributeError:
try:
inspect.getattr_static(value, field_name)
except AttributeError:
attributes = getattr(value, "__dict__", None)
has_slots = any("__slots__" in cls.__dict__ for cls in type(value).__mro__)
if isinstance(attributes, Mapping) or has_slots:
if require_row_fields:
raise AttributeError(
f"Field name {field_name!r} does not exist in "
f"{type(value).__name__}."
) from None
return None
else:
raise
raise TypeError(
f"Expected a Mapping, Row, tuple, list, or object with fields for RowType "
f"{field_names}, got {type(value).__name__}."
) from None
def _is_pandas_null(value):
import pandas as pd
return bool(pd.isna(value)) if pd.api.types.is_scalar(value) else False
def _wrap_map_pandas(func, input_type: RowType, output_type: RowType):
import pandas as pd
input_converters = [
_create_pandas_row_column_converter(field.data_type, [None] * len(field.data_type))
if isinstance(field.data_type, RowType) else None for field in input_type]
output_converters = [_create_pandas_output_column_converter(field.data_type)
for field in output_type]
@functools.wraps(func)
def wrapper(*input_columns):
batch_size = next((len(column) for column in input_columns
if isinstance(column, (pd.Series, pd.DataFrame))), None)
if batch_size is None:
raise ValueError("Pandas map_batches requires a column-valued input.")
input_index = pd.RangeIndex(batch_size)
columns = {}
for input_field, column, convert in zip(input_type, input_columns, input_converters):
# The planner can fold an input column into a scalar literal argument.
if not isinstance(column, (pd.Series, pd.DataFrame)):
if isinstance(input_field.data_type, RowType):
names = input_field.data_type.field_names()
values = ([None] * len(names) if column is None
else _row_field_values(column, names))
column = pd.DataFrame([values] * batch_size, columns=names)
else:
column = pd.Series([column] * batch_size, index=input_index,
name=input_field.name)
if convert is not None:
column = convert(column)
index = pd.RangeIndex(len(column))
if not column.index.identical(index):
# Arrow transports values positionally and discards upstream pandas indexes.
column = column.copy(deep=False)
column.index = index
columns[input_field.name] = column
result = func(columns)
output = []
for output_field, convert in zip(output_type, output_converters):
column = result[output_field.name]
container = pd.DataFrame if isinstance(output_field.data_type, RowType) else pd.Series
if not isinstance(column, container):
raise TypeError(f"Pandas map_batches output column {output_field.name!r} "
f"must be a pandas.{container.__name__}.")
if len(column) != len(input_index):
raise ValueError(
f"The result length '{len(column)}' of Pandas UDF '{wrapper.__name__}' "
f"column {output_field.name!r} is not equal to the input length "
f"'{len(input_index)}'")
if container is pd.DataFrame and type(column) is not pd.DataFrame:
# The shared Arrow converter expects the base DataFrame produced by concat.
column = pd.DataFrame(column, copy=False)
if convert is not None:
column = convert(column)
if isinstance(column, pd.Series) and column.name != output_field.name:
column = column.copy(deep=False)
column.name = output_field.name
output.append(column)
output = pd.concat(output, axis=1, copy=False)
index = output.index
if index.equals(input_index):
index = input_index
elif len(index) == len(input_index):
# Restore input order without discarding extra labels or padding short batches.
extra_labels = index[~index.isin(input_index)]
index = input_index if extra_labels.empty else input_index.append(extra_labels)
if len(index) != len(input_index):
raise ValueError(
f"The result length '{len(index)}' of Pandas UDF '{wrapper.__name__}' "
f"is not equal to the input length '{len(input_index)}'")
if not output.index.identical(index):
if output.index.equals(index):
output.index = index
else:
output = output.reindex(index)
return output
return wrapper
def _normalize_pandas_series(column, normalize):
import pandas as pd
converted = None
for index, value in enumerate(column):
normalized = normalize(value)
if normalized is not value:
if converted is None:
converted = list(column)
converted[index] = normalized
return (column if converted is None
else pd.Series(converted, name=column.name, index=column.index, dtype=object))
def _create_pandas_row_column_converter(data_type, converters):
import pandas as pd
names = data_type.field_names()
def convert_row(column):
if not isinstance(column, pd.DataFrame):
raise TypeError("Pandas ROW columns must be pandas.DataFrame. "
"Return a pandas.DataFrame from the upstream pandas UDF.")
if list(column.columns) != names:
# ROW fields are positional in Arrow transport, regardless of producer labels.
column = column.copy(deep=False)
column.columns = names
converted_columns = None
for index, convert in enumerate(converters):
if convert is not None:
original = column.iloc[:, index]
normalized = convert(original)
if normalized is not original:
if converted_columns is None:
converted_columns = [column.iloc[:, i] for i in range(len(converters))]
converted_columns[index] = normalized
return (column if converted_columns is None
else pd.concat(converted_columns, axis=1, copy=False))
return convert_row
def _create_pandas_output_column_converter(data_type):
if isinstance(data_type, RowType):
return _create_pandas_row_column_converter(
data_type, [_create_pandas_output_column_converter(field.data_type)
for field in data_type])
normalize = _create_pandas_result_normalizer(data_type)
return (None if normalize is None
else lambda column: _normalize_pandas_series(column, normalize))
def _create_pandas_result_normalizer(data_type):
"""Validate map ROW fields and adapt only values that Arrow cannot consume directly."""
if isinstance(data_type, RowType):
names = data_type.field_names()
normalizers = [_create_pandas_result_normalizer(field.data_type) for field in data_type]
def normalize_row(value):
if _is_pandas_null(value):
return None
values = _row_field_values(value, names, require_row_fields=True)
normalized = [item if normalize is None else normalize(item)
for item, normalize in zip(values, normalizers)]
if isinstance(value, dict) and all(item is original
for item, original in zip(normalized, values)):
return value
return dict(zip(names, normalized))
return normalize_row
if isinstance(data_type, ArrayType):
normalize_element = _create_pandas_result_normalizer(data_type.element_type)
if normalize_element is not None:
def normalize_array(value):
if isinstance(value, Iterator):
raise TypeError("Pandas ARRAY values containing ROW must not be an iterator; "
"return a list instead.")
if _is_pandas_null(value):
return None
converted = None
for index, item in enumerate(value):
normalized = normalize_element(item)
if normalized is not item:
if converted is None:
converted = list(value)
converted[index] = normalized
return value if converted is None else converted
return normalize_array
if isinstance(data_type, MapType):
normalize_key = _create_pandas_result_normalizer(data_type.key_type)
normalize_value = _create_pandas_result_normalizer(data_type.value_type)
def normalize_map(value):
if isinstance(value, Iterator):
raise TypeError("Pandas MAP values must not be an iterator; "
"return a dictionary or list of pairs instead.")
if _is_pandas_null(value):
return None
# Older Arrow versions require pair sequences for MAPs nested in collections.
converted = list(value.items()) if isinstance(value, Mapping) else None
if normalize_key is None and normalize_value is None:
return value if converted is None else converted
items = value if converted is None else converted
for index, (key, item) in enumerate(items):
normalized_key = key if normalize_key is None else normalize_key(key)
normalized_item = item if normalize_value is None else normalize_value(item)
if normalized_key is not key or normalized_item is not item:
if converted is None:
converted = list(value)
converted[index] = (normalized_key, normalized_item)
return value if converted is None else converted
return normalize_map
return None
def _wrap_map_arrow(func, input_type: RowType, output_type: RowType):
import pyarrow as pa
from pyflink.table.types import to_arrow_type
input_names = input_type.field_names()
input_types = [to_arrow_type(field.data_type, allow_nested=True) for field in input_type]
output_names = output_type.field_names()
@functools.wraps(func)
def wrapper(*input_columns):
batch_size = next((len(column) for column in input_columns
if isinstance(column, (pa.Array, pa.ChunkedArray))), None)
if batch_size is None:
raise ValueError("Arrow map_batches requires a column-valued input.")
inputs = {
name: column if isinstance(column, (pa.Array, pa.ChunkedArray))
else pa.array([column] * batch_size, type=data_type)
for name, data_type, column in zip(input_names, input_types, input_columns)
}
result = func(inputs)
columns = []
for name in output_names:
column = result[name]
if isinstance(column, pa.ChunkedArray):
column = column.chunk(0) if column.num_chunks == 1 else column.combine_chunks()
columns.append(column)
return pa.StructArray.from_arrays(columns, names=output_names)
return wrapper
def _create_result_normalizer(
data_type: Any, *, require_row_fields: bool = False, row_as_tuple: bool = False,
) -> Optional[Callable[[Any], Any]]:
if isinstance(data_type, RowType):
field_names = data_type.field_names()
field_normalizers = tuple(
_create_result_normalizer(
field.data_type, require_row_fields=require_row_fields, row_as_tuple=row_as_tuple)
for field in data_type
)
def normalize_row(value: Any) -> Any:
if value is None:
return None
field_values = _row_field_values(
value, field_names, require_row_fields=require_row_fields)
normalized_fields = [
field_value
if field_normalizer is None
else field_normalizer(field_value)
for field_value, field_normalizer in zip(
field_values, field_normalizers
)
]
if row_as_tuple:
return tuple(normalized_fields)
row = Row(*normalized_fields)
row.set_field_names(field_names)
if isinstance(value, Row):
row.set_row_kind(value.get_row_kind())
return row
return normalize_row
if isinstance(data_type, ArrayType):
element_normalizer = _create_result_normalizer(
data_type.element_type, require_row_fields=require_row_fields,
row_as_tuple=row_as_tuple)
if element_normalizer is None:
def normalize_leaf_array(value: Any) -> Any:
return None if value is None else list(value)
return normalize_leaf_array
def normalize_array(value: Any) -> Any:
if value is None:
return None
return [element_normalizer(item) for item in value]
return normalize_array
if isinstance(data_type, MapType):
key_normalizer = _create_result_normalizer(
data_type.key_type, require_row_fields=require_row_fields, row_as_tuple=row_as_tuple)
value_normalizer = _create_result_normalizer(
data_type.value_type, require_row_fields=require_row_fields, row_as_tuple=row_as_tuple)
def normalize_map(value: Any) -> Any:
if value is None:
return None
items_method = getattr(value, "items", None)
if callable(items_method):
items = list(cast(Iterable[Any], items_method()))
else:
try:
items = list(value)
except TypeError as exc:
raise TypeError(
f"Expected a Mapping or iterable of key/value pairs for "
f"{data_type}, got {type(value).__name__}."
) from exc
if any(
not isinstance(item, (tuple, list)) or len(item) != 2
for item in items
):
raise TypeError(
f"Expected a Mapping or iterable of key/value pairs for {data_type}, "
f"got {type(value).__name__}."
)
if any(item[0] is None for item in items):
raise TypeError(f"MapType keys must not be null for {data_type}.")
return {
key if key_normalizer is None else key_normalizer(key): (
item_value
if value_normalizer is None
else value_normalizer(item_value)
)
for key, item_value in items
}
return normalize_map
return None