[FLINK-40186][python] Introduce the PyFlink DataFrame API#28797
[FLINK-40186][python] Introduce the PyFlink DataFrame API#28797auroflow wants to merge 7 commits into
Conversation
Generated-by: OpenAI Codex (GPT-5.6 Sol)
Generated-by: OpenAI Codex (GPT-5.6 Sol)
Generated-by: OpenAI Codex (GPT-5.6 Sol)
Generated-by: OpenAI Codex (GPT-5.6 Sol)
1b7cd38 to
9684ffc
Compare
|
|
||
| self.assertEqual(result.collect(), [Row(1, "Alice", 31)]) | ||
|
|
||
| def test_from_dict_respects_schema_order_and_subset(self): |
There was a problem hiding this comment.
It has introduced about 30 it cases. Please check if we could merge them. Besides, it's also worth to check if it's possible to convert part of them into unit tests.
There was a problem hiding this comment.
I've converted most of them to unit tests that check the resulting DataFrame schema. I kept two IT cases to test the results of basic DataFrame workflows.
| ) | ||
| field_rows.append(tuple(field_values[name] for name in columns)) | ||
| rows = field_rows | ||
| elif isinstance(first_record, Sequence): |
There was a problem hiding this comment.
The isinstance(record, Sequence) check also accepts str, bytes, and similar scalar values. For example: from_records(["ab", "cd"], schema=["x", "y"]) produces rows ("a", "b") and ("c", "d") which is not expected.
There was a problem hiding this comment.
Makes sense! This could become a misuse pattern. I added validation for str, bytes, bytearray, and memoryview.
|
|
||
| .. versionadded:: 2.4.0 | ||
| """ | ||
| if not isinstance(data, Mapping): |
There was a problem hiding this comment.
The column validation also accepts strings because they implement Sequence:
from_dict({"name": "Bob"}) would create three rows containing "B", "o", and "b".
|
|
||
|
|
||
| @PublicEvolving() | ||
| def set_table_environment(t_env: Optional["StreamTableEnvironment"]) -> None: |
There was a problem hiding this comment.
validate the type of the given t_env
| for name in columns: | ||
| if name not in field_values: | ||
| raise ValueError( | ||
| "schema field %r is not present in records" % name |
There was a problem hiding this comment.
Since validation is performed per record, the message should identify the failing index consistently.
There was a problem hiding this comment.
I included the failing index in the error message.
| __all__ = ["from_dict", "from_records"] | ||
|
|
||
|
|
||
| class _SequenceRowIterable: |
There was a problem hiding this comment.
Why are we creating custom iterables? This class looks like it could be replaced with a simple:
rows = (tuple(record) for record in sequence_rows)on line 178
There was a problem hiding this comment.
I intended to use them to make the rows reusable, because the Table API layer iterates the input twice, first during schema inference, then during conversion. A bare generator expression is exhausted by the first pass and produces an empty table.
But I do agree the custom iterables are rather confusing. I changed both with list comprehensions, because these conversion methods are mainly for local debugging with small inputs, so we could afford some extra allocations in favor of readability here. WDYT?
There was a problem hiding this comment.
Yeah I'm completely fine with list comprehensions. I assume the bulk of memory will be in the data objects themselves and since we aren't copying those this should just increase the memory consumption by a paltry 8 bytes * len(rows). Thanks for the extra context!
| return (tuple(record) for record in self._records) | ||
|
|
||
|
|
||
| class _ColumnRowIterable: |
There was a problem hiding this comment.
Same comment as _SequenceRowIterable except this class also doesn't appear to be used at all
There was a problem hiding this comment.
It was used by from_dict. I've changed it to a list comprehension.
|
|
||
| first_record = data[0] | ||
| rows: Iterable[Sequence[Any]] | ||
| named_tuple_records = _is_named_tuple_record(first_record) |
There was a problem hiding this comment.
nit: can we change the name from named_tuple_records to is_named_tuple_record? The current naming implies a collection of tuple records rather than a boolean
There was a problem hiding this comment.
I removed it as part of the enum refactor.
|
|
||
|
|
||
| def _to_field_mapping( | ||
| record: Any, named_tuple_records: bool |
There was a problem hiding this comment.
Is the purpose of the named_tuple_records boolean enforcement of standardization? If so I feel like a name change would help clarify things. Something like:
def _to_field_mapping(record: Any, enforce_named_tuple: bool):
...without seeing usage this kinda reads like redundant operations as is.
There was a problem hiding this comment.
Yes, that was the intent, I changed the name according to your other comment.
|
|
||
|
|
||
| def _to_field_mapping( | ||
| record: Any, named_tuple_records: bool |
There was a problem hiding this comment.
I feel like the type of record could be described in a more readable fashion with an enum:
from enum import Enum
class RecordType(Enum)
NamedTuple = '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.NamedTuple
elif isinstance(record, Mapping):
return cls.Mapping
elif isinstance(record, Sequence):
return cls.Sequence
else:
raise TypeError("records must be mappings or sequences")this makes the supported types more obvious and encourages variable names that I think would better encompass intent.
Current usage could look like:
def from_records(
data: Sequence[Union[Sequence[Any], Mapping[str, Any]]],
schema: Optional[List[str]] = None,
) -> DataFrame:
...
expected_record_type = RecordType.from_record(first_record)
...
if expected_record_type in [RecordType.Mapping, RecordType.NamedTuple]:
first_fields = _to_field_mapping(first_record, expected_record_type)
...
else: # We know it must be a RecordType.Sequence since we are enforcing this on creation
...def _to_field_mapping(record: Any, expected_type: RecordType) -> Mapping[str, Any]:
record_type = RecordType.from_record(record)
if expected_type == RecordType.NamedTuple:
if record_type != expected_type:
raise TypeError("records must all be named tuples")
...
elif record_type != RecordType.Mapping:
raise TypeError("records must all be mappings")
# We should probably handle a sequence type here for completeness
return cast(Mapping[str, Any], record)looking forward this would also work really well with structural pattern matching once the lib drops support for python 3.9:
match RecordType.from_record(record):
case RecordType.NamedTuple | RecordType.Mapping:
# Do something
case RecordType.Sequence:
# Do something else
case _:
raise TypeError('unsupported record type')There was a problem hiding this comment.
Good idea! This is much clearer. I've refactored this part.
| field_rows: List[Sequence[Any]] = [] | ||
| for index, record in enumerate(data): | ||
| field_values = _to_field_mapping(record, named_tuple_records) | ||
| if schema is None and set(field_values.keys()) != expected_fields: |
There was a problem hiding this comment.
we have already validated that columns has unique fields. Do we expect the fields to have identical orderings? If so I think we can get rid of expected_fields and just do:
list(field_values.keys()) != columnsThere was a problem hiding this comment.
Yes, I was hoping to support non-identical orderings, so I'd prefer keeping it.
There was a problem hiding this comment.
Fair enough. I'm fine with this then.
| from pyflink.table import StreamTableEnvironment | ||
|
|
||
|
|
||
| _global_table_environment: Optional["StreamTableEnvironment"] = None |
There was a problem hiding this comment.
Might be slightly out of scope, but thoughts on making this a ContextVar? It wouldn't make much difference with the current planned API, but with the addition of a function like:
from contextlib import contextmanager
@contextmanager
def table_environment(t_env):
global _global_table_environment
with _global_table_environment.set(t_env):
yield _global_table_environment.get()users would be able to selectively change their environments without worrying about mutating global state:
my_environment = ...
set_table_environment(my_environment)
assert get_table_environment() == my_environment
# Start a temp environment
temp_environment = ...
with table_environment(temp_environment):
assert get_table_environment() == temp_environment
assert get_table_environment() == my_environmentthis would better support async or multithreaded environments
There was a problem hiding this comment.
Maybe we could do it in another PR? In my understanding, the global context management here is intended to abstract the concept of Flink's table environment away to make the DataFrame API cleaner, since most users use one single environment in a Flink job. We could do this once we find concrete multi-environment use cases
There was a problem hiding this comment.
Fair. Just figured I'd throw out the suggestion for consideration.
Generated-by: OpenAI Codex (GPT-5.6 Sol)
Generated-by: OpenAI Codex (GPT-5)
Generated-by: OpenAI Codex (GPT-5)
| raise ValueError("schema field names must be unique") | ||
|
|
||
|
|
||
| def _validate_record_type( |
There was a problem hiding this comment.
this feels like it should live under the _RecordType enum like:
class _RecordType(Enum):
...
def validate(self, record: Any):
try:
record_type = _RecordType.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("_", " ")}')doing this completely eliminates the error class of "unsupported expected record type"
| ): | ||
| return | ||
| if expected_record_type is _RecordType.NAMED_TUPLE: | ||
| raise TypeError("record at index %d must be a named tuple" % index) |
There was a problem hiding this comment.
make these f-strings. They are faster and easier to read than printf style formatting. printf style format strings are only really useful in log messages when you want to lazily interpolate strings on levels that may not be relevant at runtime
| record, _SCALAR_SEQUENCE_TYPES | ||
| ): | ||
| return cls.SEQUENCE | ||
| raise TypeError |
There was a problem hiding this comment.
I feel like you should give this an error message like:
raise TypeError(
"records must be a mapping or a sequence of values, "
"such as a list or tuple"
)| try: | ||
| expected_record_type = _RecordType.from_record(first_record) | ||
| except TypeError: | ||
| raise TypeError( |
There was a problem hiding this comment.
it'd simplify code complexity if you stacked the error messages. Assuming you took my above suggestion, you could change this to:
try:
expected_record_type = _RecordType.from_record(first_record)
except TypeError error:
raise TypeError('invalid record at index 0') from errorand the users would get the same information, but without you needing to pass around the index everywhere.
This strategy could also be applied to the validation method I suggested like:
for index, record in enumerate(data):
try:
expected_record.validate(record)
except TypeError as error:
raise TypeError(f'invalid record at index {index}') from errorThis should read better, result in more uniform error messages, and be less annoying to maintain in the future.
| _validate_record_type(record, expected_record_type, index) | ||
| if len(record) != len(columns): | ||
| raise ValueError( | ||
| "record at index %d has %d values but schema has %d fields" |
There was a problem hiding this comment.
|
|
||
| first_record = data[0] | ||
| rows: Iterable[Sequence[Any]] | ||
| named_tuple_records = _is_named_tuple_record(first_record) |
|
|
||
|
|
||
| def _to_field_mapping( | ||
| record: Any, named_tuple_records: bool |
|
|
||
|
|
||
| def _to_field_mapping( | ||
| record: Any, named_tuple_records: bool |
| field_rows: List[Sequence[Any]] = [] | ||
| for index, record in enumerate(data): | ||
| field_values = _to_field_mapping(record, named_tuple_records) | ||
| if schema is None and set(field_values.keys()) != expected_fields: |
There was a problem hiding this comment.
Fair enough. I'm fine with this then.
| from pyflink.table import StreamTableEnvironment | ||
|
|
||
|
|
||
| _global_table_environment: Optional["StreamTableEnvironment"] = None |
There was a problem hiding this comment.
Fair. Just figured I'd throw out the suggestion for consideration.
What is the purpose of the change
This pull request introduces the foundation for a Pythonic DataFrame API in PyFlink.
Brief change log
pyflink.dataframepackage and its public evolving interfaces.Verifying this change
This change added tests and can be verified as follows:
Does this pull request potentially affect one of the following parts:
@Public(Evolving): yesDocumentation
Was generative AI tooling used to co-author this PR?
Generated-by: OpenAI Codex (GPT-5.6 Sol)