This is an automated email from the ASF dual-hosted git repository.
dianfu pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/flink.git
The following commit(s) were added to refs/heads/master by this push:
new a6c87283904 [FLINK-40426][python] Add Parquet and JSON filesystem I/O
to DataFrame API (#29245)
a6c87283904 is described below
commit a6c872839042cc8b8baa8aabc80f6eaaa8b65b3d
Author: csurong <[email protected]>
AuthorDate: Thu Sep 24 15:36:19 2026 +0800
[FLINK-40426][python] Add Parquet and JSON filesystem I/O to DataFrame API
(#29245)
---
.../docs/reference/pyflink.dataframe/io.rst | 205 +++++
flink-python/pyflink/dataframe/__init__.py | 4 +-
flink-python/pyflink/dataframe/dataframe.py | 290 ++++++-
flink-python/pyflink/dataframe/io.py | 285 ++++++-
.../pyflink/dataframe/tests/test_filesystem_io.py | 860 +++++++++++++++++++++
flink-python/pyflink/dataframe/tests/test_io.py | 1 +
6 files changed, 1635 insertions(+), 10 deletions(-)
diff --git a/flink-python/docs/reference/pyflink.dataframe/io.rst
b/flink-python/docs/reference/pyflink.dataframe/io.rst
index aab2b109f04..a5c34a926ab 100644
--- a/flink-python/docs/reference/pyflink.dataframe/io.rst
+++ b/flink-python/docs/reference/pyflink.dataframe/io.rst
@@ -33,6 +33,8 @@ Readers
:toctree: api/
read_generic
+ read_json
+ read_parquet
Writers
-------
@@ -43,3 +45,206 @@ Writers
:toctree: api/
DataFrame.write_generic
+ DataFrame.write_json
+ DataFrame.write_parquet
+
+Filesystem sources and sinks
+----------------------------
+
+``read_json`` reads newline-delimited JSON; ``read_parquet`` reads Parquet
files.
+Both readers require an explicit schema and use the existing filesystem
connector.
+The connector and format JARs must be available to Flink. Parquet also requires
+Hadoop libraries on the classpath, which can be provided through
``HADOOP_CLASSPATH``.
+
+For example, convert JSON records to partitioned Parquet files::
+
+ import pyflink.dataframe as pf
+
+ pf.config.set("execution.runtime-mode", "batch")
+ events = pf.read_json(
+ "file:///tmp/events.json",
+ schema={"id": pf.DataType.int64(), "event": pf.DataType.string()},
+ ignore_parse_errors=True,
+ )
+ events.write_parquet(
+ "file:///tmp/events-parquet", compression="GZIP", partition_by="event"
+ )
+
+Partitioned data
+~~~~~~~~~~~~~~~~
+
+``partition_by`` accepts a column name or a non-empty list of column names.
The order
+determines the directory layout, such as ``event=login/day=2026-01-01/``. The
filesystem
+connector stores partition values in these paths and excludes the columns from
the file
+records. Readers require the partition columns in both ``schema`` and
``partition_by``
+to restore their values. Null and empty string partition values share the
default
+partition directory. Its name can be configured using
+``connector_options={"partition.default-name": "..."}``. The current
filesystem connector
+cannot read these default partitions: planning fails while reconstructing
their paths.
+Use non-null, non-empty partition values when the output needs to be read back.
+
+The writers default to ``mode="append"`` in both batch and streaming
execution, adding files
+without replacing existing data. Explicit ``mode="overwrite"`` is supported
only in batch
+execution; the filesystem connector rejects it in streaming execution. For a
non-partitioned
+sink, overwrite replaces the existing output data. For a partitioned sink,
only partitions
+present in the input are overwritten; other partitions are retained. Configure
+``execution.runtime-mode`` before creating the DataFrame environment. Writes
wait
+for completion with local or MiniCluster execution, just like
``write_generic``.
+An unbounded local write therefore continues waiting until the job terminates.
+
+Options and defaults
+~~~~~~~~~~~~~~~~~~~~
+
+All four helpers accept ``connector_options`` for filesystem options and
``format_options``
+for JSON or Parquet options. Both dictionaries require string keys and values.
Format keys
+may include or omit the current format prefix, such as ``compression`` or
+``parquet.compression``. Specifying both forms of the same key is rejected.
+``connector_options`` cannot contain ``connector``, ``path`` or ``format``;
use the dedicated
+APIs and parameters to select these. Format options belong in
``format_options`` and are
+prefixed with the format name. The bare ``connector`` key is rejected in
either dictionary.
+
+Optional connector and format parameters use ``None`` to mean unspecified.
Dictionary values
+are merged with explicitly supplied parameters. Options absent from both are
omitted, so the
+connector or format factory applies its defaults. Supplying both forms is
allowed if their
+string values agree; conflicting values raise ``ValueError``.
+Values are compared as strings: ``"1min"`` and ``"60s"`` are different
settings for this
+check. The dictionaries supplied by the caller are not modified.
+
+For example, this uses GZIP rather than the default SNAPPY::
+
+ events.write_parquet(
+ "file:///tmp/output",
+ mode="append",
+ format_options={"compression": "GZIP"},
+ connector_options={"sink.parallelism": "2"},
+ )
+
+The current format default for Parquet compression is ``"SNAPPY"``. The
default file rolling
+threshold is ``"128mb"``, rollover and inactivity thresholds are ``"30min"``,
and the
+time-based rolling check interval is ``"1min"``. Partition commit defaults to
+``"process-time"`` with a ``"0s"`` delay and no policy. JSON defaults to SQL
timestamp
+formatting, does not ignore parse errors, and does not fail on missing fields.
Parquet's
+``utc-timezone`` option defaults to ``False``, using the JVM default time zone
for timestamp
+conversion. This option is independent of the session time zone and the
partition commit time zone.
+
+``write_json`` also exposes ``ignore_null_fields`` and
``decimal_as_plain_number``. The first
+omits fields with null values from JSON objects; it does not drop the entire
record. The second
+writes DECIMAL values as plain numbers instead of scientific notation, while
retaining JSON
+numeric values rather than strings. The format default for both options is
currently ``False``.
+
+The helpers check Python argument types and conflicting options. Connector-
and format-specific
+validation is delegated to the underlying implementations. Options without a
convenience
+parameter, including partition time extractors, custom commit policies and
Parquet tuning
+options, remain available through these dictionaries.
+
+Format-specific options
+~~~~~~~~~~~~~~~~~~~~~~~
+
+Parquet writes timestamps as INT96 by default. To use INT64 with a timestamp
logical type,
+set ``write.int64.timestamp`` through ``format_options``. The
``timestamp.time.unit`` option
+then selects ``millis``, ``micros`` (the default) or ``nanos``. Milliseconds
discard precision
+below a millisecond; choose the unit required by the consuming system. For
example::
+
+ timestamps = pf.sql("SELECT TIMESTAMP '2026-01-02 03:04:05.123456' AS ts")
+ timestamps.write_parquet(
+ "file:///tmp/timestamps",
+ utc_timezone=True,
+ format_options={
+ "write.int64.timestamp": "true",
+ "timestamp.time.unit": "micros",
+ },
+ )
+
+JSON null fields and Map entries with null keys have separate settings.
``ignore_null_fields``
+omits null fields from rows; it does not control null Map keys. The
``map-null-key.mode`` format
+option defaults to ``"FAIL"``. ``"DROP"`` removes entries with null keys,
while ``"LITERAL"``
+replaces each null key with the string configured by ``map-null-key.literal``.
For example::
+
+ records = pf.sql(
+ "SELECT CAST(NULL AS STRING) AS name, "
+ "MAP[CAST(NULL AS STRING), 1, 'known', 2] AS attributes"
+ )
+ records.write_json(
+ "file:///tmp/records",
+ ignore_null_fields=True,
+ format_options={
+ "map-null-key.mode": "LITERAL",
+ "map-null-key.literal": "missing",
+ },
+ )
+
+The JSON record omits ``name`` and contains ``{"attributes": {"missing": 1,
"known": 2}}``.
+
+Continuous reads and event time
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+When ``source.monitor-interval`` is unset, readers scan the configured paths
once and produce
+a bounded source. Setting a positive interval, such as ``"60s"``, through
``monitor_interval``
+or ``connector_options`` enables continuous discovery and requires streaming
execution.
+It does not change the environment's execution mode.
+Each file is identified by its path and processed once; discovery does not
tail files
+for appended records or re-read files changed in place.
+
+For partitioned sources, Flink discovers the partition directories during
planning and
+monitors those paths. It does not discover new partition directories. If no
partitions
+exist initially, the source remains empty even when ``monitor_interval`` is
set.
+
+``path_regex_pattern`` uses Java regular expressions and matches the entire
file path,
+excluding the URI scheme and authority. It is not a substring search or a
filename-only
+match. For example, use ``r".*/events[.]json"`` to select files named
``events.json``.
+Hidden files and directories are excluded.
+
+Both readers support ``computed_columns`` and ``watermark``, using the same
schema
+declarations as ``read_generic``. A watermark needs a TIMESTAMP or
TIMESTAMP_LTZ column
+with precision from 0 to 3, such as ``pf.DataType.timestamp(3)``.
+
+Run the following example in a fresh environment. When using
``set_table_environment``,
+configure the injected environment for streaming first::
+
+ import pyflink.dataframe as pf
+
+ pf.config.set("execution.runtime-mode", "streaming")
+ incoming_events = pf.read_json(
+ "file:///tmp/incoming",
+ schema={"id": pf.DataType.int64(), "ts_millis": pf.DataType.int64()},
+ monitor_interval="10s",
+ computed_columns={"event_time": "TO_TIMESTAMP_LTZ(ts_millis, 3)"},
+ watermark=("event_time", "event_time - INTERVAL '5' SECOND"),
+ )
+
+Partition shuffle and compaction
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+Both writers expose ``sink_shuffle_by_partition`` to redistribute records by
dynamic partition
+fields before writing. This can reduce the number of files but may cause data
skew for heavily
+populated partitions. The connector default is currently ``False``.
+
+For streaming writes, ``auto_compaction=True`` enables automatic compaction
after checkpoints
+complete. Files remain invisible until compaction finishes. This reduces small
files at the cost
+of additional I/O. ``compaction_file_size`` sets the target size for compacted
files; it is not a
+hard size limit. If unspecified, the connector uses the rolling policy file
size.
+Automatic compaction is disabled by default.
+
+Streaming writes
+~~~~~~~~~~~~~~~~
+
+Rolling policy parameters apply to streaming writes, not batch writes. File
size and time
+settings are rolling thresholds, not hard limits on output file size or
visibility latency.
+Continuous writes need checkpointing to finish pending files. Without
automatic compaction,
+JSON also needs a rolling condition to close the file. Parquet and writers
with automatic
+compaction enabled roll on checkpoints as well as on size/time thresholds.
+Configure ``execution.checkpointing.interval`` through the environment
configuration before
+execution; these helpers do not enable checkpointing automatically.
+
+Streaming partition commit requires ``partition_by`` and a policy such as
``"success-file"``.
+The ``"partition-time"`` trigger also needs upstream watermarks and partition
time extraction
+settings in ``connector_options``. If the watermark is declared on a
TIMESTAMP_LTZ column,
+set ``sink.partition-commit.watermark-time-zone`` to the session time zone.
For example,
+with ``table.local-time-zone="Asia/Shanghai"``, pass
+``connector_options={"sink.partition-commit.watermark-time-zone":
"Asia/Shanghai"}``.
+Leaving this option at its UTC default with a non-UTC session time zone can
shift partition
+commit times by hours. For a TIMESTAMP watermark, keep the UTC default.
+
+A success marker is a partition commit notification; it does not prevent later
records from
+being written to the partition. The ``"metastore"`` policy is specific to Hive
tables and
+cannot be used with these filesystem helpers.
diff --git a/flink-python/pyflink/dataframe/__init__.py
b/flink-python/pyflink/dataframe/__init__.py
index bce542f48c2..94697f87029 100644
--- a/flink-python/pyflink/dataframe/__init__.py
+++ b/flink-python/pyflink/dataframe/__init__.py
@@ -54,7 +54,7 @@ from pyflink.dataframe.context import (
from pyflink.dataframe.dataframe import DataFrame, GroupedDataFrame, col, lit
from pyflink.dataframe.dataframe_config import config
from pyflink.dataframe.datatype import DataType
-from pyflink.dataframe.io import read_generic
+from pyflink.dataframe.io import read_generic, read_json, read_parquet
from pyflink.dataframe.sql import sql
from pyflink.dataframe.udf import udf
from pyflink.dataframe.udtf import udtf
@@ -74,6 +74,8 @@ __all__ = [
"from_table",
"range",
"read_generic",
+ "read_json",
+ "read_parquet",
"sql",
"config",
"set_table_environment",
diff --git a/flink-python/pyflink/dataframe/dataframe.py
b/flink-python/pyflink/dataframe/dataframe.py
index 0341b1c8f8c..119d48b06b6 100644
--- a/flink-python/pyflink/dataframe/dataframe.py
+++ b/flink-python/pyflink/dataframe/dataframe.py
@@ -1504,6 +1504,278 @@ class DataFrame:
# ======================== I/O ========================
+ @PublicEvolving()
+ def write_parquet(
+ self,
+ path: str,
+ *,
+ mode: str = "append",
+ partition_by: Optional[Union[str, List[str]]] = None,
+ compression: Optional[str] = None,
+ utc_timezone: Optional[bool] = None,
+ sink_parallelism: Optional[int] = None,
+ sink_shuffle_by_partition: Optional[bool] = None,
+ auto_compaction: Optional[bool] = None,
+ compaction_file_size: Optional[str] = None,
+ rolling_policy_file_size: Optional[str] = None,
+ rolling_policy_rollover_interval: Optional[str] = None,
+ rolling_policy_inactivity_interval: Optional[str] = None,
+ rolling_policy_check_interval: Optional[str] = None,
+ partition_commit_trigger: Optional[str] = None,
+ partition_commit_delay: Optional[str] = None,
+ partition_commit_policy_kind: Optional[str] = None,
+ connector_options: Optional[Dict[str, str]] = None,
+ format_options: Optional[Dict[str, str]] = None,
+ ) -> None:
+ """
+ Write Parquet files using Flink's filesystem connector.
+
+ The filesystem connector and Parquet format must be available to
Flink. The write
+ is submitted immediately and waits for completion for local or
MiniCluster execution.
+ Sink columns are derived from this DataFrame's schema. Writes default
to append in both
+ batch and streaming execution. Explicit overwrite requires batch
execution and is rejected
+ by the filesystem connector in streaming execution. Partitioned
overwrite replaces only
+ partitions present in the input, retaining other partitions.
+
+ Optional connector and format parameters use ``None`` to leave the
option unspecified.
+ If neither a parameter nor its dictionary option is set, the connector
or format factory
+ supplies the default.
+
+ Rolling policies apply to streaming sinks. Parquet also rolls files on
checkpoints;
+ continuous writes require checkpointing to finish files. Partition
commit in streaming
+ requires ``partition_by`` and a commit policy. ``partition-time``
additionally requires
+ upstream watermarks and a partition time extractor, configured via
``connector_options``.
+ For a TIMESTAMP_LTZ watermark, set
``sink.partition-commit.watermark-time-zone`` in
+ ``connector_options`` to the session time zone; its default is UTC.
+
+ :param path: Output directory URI supported by Flink's filesystem
implementations.
+ :param mode: ``"append"`` (default) adds files; ``"overwrite"``
replaces existing data
+ in batch execution only.
+ :param partition_by: Partition column name or non-empty list of names
in directory order.
+ Values are stored in Hive-style partition paths rather than in the
Parquet records.
+ :param compression: Parquet compression codec. The format default is
currently
+ ``"SNAPPY"``.
+ :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 sink_parallelism: Sink parallelism. The connector default is
the upstream
+ parallelism.
+ :param sink_shuffle_by_partition: Shuffle rows by dynamic partition
fields before writing.
+ This can reduce the number of files but may cause data skew. The
connector default
+ is currently ``False``.
+ :param auto_compaction: Automatically compact files in streaming
execution after
+ checkpoints complete. Files remain invisible until compaction
finishes. The connector
+ default is currently ``False``.
+ :param compaction_file_size: Target file size for automatic
compaction, for example
+ ``"128mb"``. The connector default is the rolling policy file size.
+ :param rolling_policy_file_size: Part file size threshold for rolling,
not a hard upper
+ bound. The connector default is currently ``"128mb"``.
+ :param rolling_policy_rollover_interval: Part file open-time
threshold. The connector
+ default is currently ``"30min"``.
+ :param rolling_policy_inactivity_interval: Part file inactivity
threshold. The connector
+ default is currently ``"30min"``.
+ :param rolling_policy_check_interval: Interval for checking time-based
rolling policies.
+ The connector default is currently ``"1min"``.
+ :param partition_commit_trigger: Partition commit trigger:
``"process-time"`` or
+ ``"partition-time"``. The connector default is currently
``"process-time"``.
+ :param partition_commit_delay: Delay before committing a partition.
The connector
+ default is currently ``"0s"``.
+ :param partition_commit_policy_kind: Optional comma-separated
policies, such as
+ ``"success-file"`` or ``"custom"``. The ``metastore`` policy
requires a Hive table.
+ :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. Unspecified options are left to the connector factory.
+ :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.
+ For INT64 timestamp encoding, use ``{"write.int64.timestamp":
"true",
+ "timestamp.time.unit": "micros"}``. The default encoding is INT96.
+ :raises TypeError: If an argument has an invalid type.
+ :raises ValueError: If the path is empty, the write mode is
unsupported, 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
+ >>> _ = pf.config.set("execution.runtime-mode", "batch")
+ >>> events = pf.from_records([(1, "login")], schema=["id",
"event"])
+ >>> events.write_parquet("file:///tmp/events", compression="GZIP")
+
+ .. versionadded:: 2.4.0
+ """
+ from pyflink.dataframe.io import (
+ _boolean_option,
+ _build_filesystem_options,
+ _parallelism_option,
+ )
+
+ options = _build_filesystem_options(
+ path,
+ "parquet",
+ connector_parameters={
+ "sink.parallelism": _parallelism_option(sink_parallelism),
+ "sink.shuffle-by-partition.enable": _boolean_option(
+ sink_shuffle_by_partition, "sink_shuffle_by_partition"),
+ "auto-compaction": _boolean_option(auto_compaction,
"auto_compaction"),
+ "compaction.file-size": compaction_file_size,
+ "sink.rolling-policy.file-size": rolling_policy_file_size,
+ "sink.rolling-policy.rollover-interval":
rolling_policy_rollover_interval,
+ "sink.rolling-policy.inactivity-interval":
rolling_policy_inactivity_interval,
+ "sink.rolling-policy.check-interval":
rolling_policy_check_interval,
+ "sink.partition-commit.trigger": partition_commit_trigger,
+ "sink.partition-commit.delay": partition_commit_delay,
+ "sink.partition-commit.policy.kind":
partition_commit_policy_kind,
+ },
+ format_parameters={
+ "compression": compression,
+ "utc-timezone": _boolean_option(utc_timezone, "utc_timezone"),
+ },
+ extra_connector_options=connector_options,
+ extra_format_options=format_options,
+ )
+ self._write("filesystem", options, mode=mode,
partition_by=partition_by)
+
+ @PublicEvolving()
+ def write_json(
+ self,
+ path: str,
+ *,
+ mode: str = "append",
+ partition_by: Optional[Union[str, List[str]]] = None,
+ timestamp_format: Optional[str] = None,
+ ignore_null_fields: Optional[bool] = None,
+ decimal_as_plain_number: Optional[bool] = None,
+ sink_parallelism: Optional[int] = None,
+ sink_shuffle_by_partition: Optional[bool] = None,
+ auto_compaction: Optional[bool] = None,
+ compaction_file_size: Optional[str] = None,
+ rolling_policy_file_size: Optional[str] = None,
+ rolling_policy_rollover_interval: Optional[str] = None,
+ rolling_policy_inactivity_interval: Optional[str] = None,
+ rolling_policy_check_interval: Optional[str] = None,
+ partition_commit_trigger: Optional[str] = None,
+ partition_commit_delay: Optional[str] = None,
+ partition_commit_policy_kind: Optional[str] = None,
+ connector_options: Optional[Dict[str, str]] = None,
+ format_options: Optional[Dict[str, str]] = None,
+ ) -> None:
+ """
+ Write newline-delimited JSON files using Flink's filesystem connector.
+
+ The filesystem connector and JSON format must be available to Flink.
The write
+ is submitted immediately and waits for completion for local or
MiniCluster execution.
+ Sink columns are derived from this DataFrame's schema. Writes default
to append in both
+ batch and streaming execution. Explicit overwrite requires batch
execution and is rejected
+ by the filesystem connector in streaming execution. Partitioned
overwrite replaces only
+ partitions present in the input, retaining other partitions.
+
+ Optional connector and format parameters use ``None`` to leave the
option unspecified.
+ If neither a parameter nor its dictionary option is set, the connector
or format factory
+ supplies the default.
+
+ Rolling policies apply to streaming sinks. Continuous writes require
both file rolling
+ and checkpointing to finish files. With automatic compaction, files
also roll on
+ checkpoints. Partition commit in streaming requires ``partition_by``
+ and a commit policy. ``partition-time`` additionally requires upstream
watermarks and a
+ partition time extractor, configured via ``connector_options``.
+ For a TIMESTAMP_LTZ watermark, set
``sink.partition-commit.watermark-time-zone`` in
+ ``connector_options`` to the session time zone; its default is UTC.
+
+ :param path: Output directory URI supported by Flink's filesystem
implementations.
+ :param mode: ``"append"`` (default) adds files; ``"overwrite"``
replaces existing data
+ in batch execution only.
+ :param partition_by: Partition column name or non-empty list of names
in directory order.
+ Values are stored in Hive-style partition paths rather than in the
JSON records.
+ :param timestamp_format: Timestamp representation, ``"SQL"`` or
``"ISO-8601"``.
+ The format default is currently ``"SQL"``.
+ :param ignore_null_fields: Omit fields with null values from JSON
objects. The format
+ default is currently ``False``. This does not control Map entries
with null keys;
+ configure ``map-null-key.mode`` through ``format_options`` for
those entries.
+ :param decimal_as_plain_number: Encode DECIMAL values as plain numbers
rather than
+ scientific notation, retaining JSON numeric values. The format
default is currently
+ ``False``.
+ :param sink_parallelism: Sink parallelism. The connector default is
the upstream
+ parallelism.
+ :param sink_shuffle_by_partition: Shuffle rows by dynamic partition
fields before writing.
+ This can reduce the number of files but may cause data skew. The
connector default
+ is currently ``False``.
+ :param auto_compaction: Automatically compact files in streaming
execution after
+ checkpoints complete. Files remain invisible until compaction
finishes. The connector
+ default is currently ``False``.
+ :param compaction_file_size: Target file size for automatic
compaction, for example
+ ``"128mb"``. The connector default is the rolling policy file size.
+ :param rolling_policy_file_size: Part file size threshold for rolling,
not a hard upper
+ bound. The connector default is currently ``"128mb"``.
+ :param rolling_policy_rollover_interval: Part file open-time
threshold. The connector
+ default is currently ``"30min"``.
+ :param rolling_policy_inactivity_interval: Part file inactivity
threshold. The connector
+ default is currently ``"30min"``.
+ :param rolling_policy_check_interval: Interval for checking time-based
rolling policies.
+ The connector default is currently ``"1min"``.
+ :param partition_commit_trigger: Partition commit trigger:
``"process-time"`` or
+ ``"partition-time"``. The connector default is currently
``"process-time"``.
+ :param partition_commit_delay: Delay before committing a partition.
The connector
+ default is currently ``"0s"``.
+ :param partition_commit_policy_kind: Optional comma-separated
policies, such as
+ ``"success-file"`` or ``"custom"``. The ``metastore`` policy
requires a Hive table.
+ :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. Unspecified options are left to the connector factory.
+ :param format_options: JSON format options with string values. Keys
may include or omit
+ the ``json.`` prefix, for example ``{"timestamp-format.standard":
"ISO-8601"}``.
+ ``None`` parameters leave dictionary values unchanged; conflicting
explicit values
+ are rejected.
+ :raises TypeError: If an argument has an invalid type.
+ :raises ValueError: If the path is empty, the write mode is
unsupported, 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.from_records([(1, "login")], schema=["id",
"event"])
+ >>> events.write_json("file:///tmp/events")
+
+ .. versionadded:: 2.4.0
+ """
+ from pyflink.dataframe.io import (
+ _boolean_option,
+ _build_filesystem_options,
+ _parallelism_option,
+ )
+
+ options = _build_filesystem_options(
+ path,
+ "json",
+ connector_parameters={
+ "sink.parallelism": _parallelism_option(sink_parallelism),
+ "sink.shuffle-by-partition.enable": _boolean_option(
+ sink_shuffle_by_partition, "sink_shuffle_by_partition"),
+ "auto-compaction": _boolean_option(auto_compaction,
"auto_compaction"),
+ "compaction.file-size": compaction_file_size,
+ "sink.rolling-policy.file-size": rolling_policy_file_size,
+ "sink.rolling-policy.rollover-interval":
rolling_policy_rollover_interval,
+ "sink.rolling-policy.inactivity-interval":
rolling_policy_inactivity_interval,
+ "sink.rolling-policy.check-interval":
rolling_policy_check_interval,
+ "sink.partition-commit.trigger": partition_commit_trigger,
+ "sink.partition-commit.delay": partition_commit_delay,
+ "sink.partition-commit.policy.kind":
partition_commit_policy_kind,
+ },
+ format_parameters={
+ "timestamp-format.standard": timestamp_format,
+ "encode.ignore-null-fields": _boolean_option(
+ ignore_null_fields, "ignore_null_fields"),
+ "encode.decimal-as-plain-number": _boolean_option(
+ decimal_as_plain_number, "decimal_as_plain_number"),
+ },
+ extra_connector_options=connector_options,
+ extra_format_options=format_options,
+ )
+ self._write("filesystem", options, mode=mode,
partition_by=partition_by)
+
@PublicEvolving()
def write_generic(self, connector: str, *, options: Dict[str, str]) ->
None:
"""
@@ -1533,10 +1805,24 @@ class DataFrame:
.. versionadded:: 2.4.0
"""
+ self._write(connector, options)
+
+ def _write(
+ self,
+ connector: str,
+ options: Dict[str, str],
+ *,
+ mode: str = "append",
+ partition_by: Optional[Union[str, List[str]]] = None,
+ ) -> None:
from pyflink.dataframe.io import _build_generic_descriptor
- descriptor = _build_generic_descriptor(connector, options)
- result = self._table.execute_insert(descriptor)
+ if not isinstance(mode, str):
+ raise TypeError("mode must be a string")
+ if mode not in ("append", "overwrite"):
+ raise ValueError("mode must be 'append' or 'overwrite'")
+ descriptor = _build_generic_descriptor(connector, options,
partition_by=partition_by)
+ result = self._table.execute_insert(descriptor, overwrite=mode ==
"overwrite")
execution_target = self._table._t_env.get_config().get(
"execution.target", None
)
diff --git a/flink-python/pyflink/dataframe/io.py
b/flink-python/pyflink/dataframe/io.py
index 05a7c0aedcd..0f629674690 100644
--- a/flink-python/pyflink/dataframe/io.py
+++ b/flink-python/pyflink/dataframe/io.py
@@ -16,15 +16,261 @@
# limitations under the License.
################################################################################
-from typing import Dict, Optional, Tuple
+from typing import Dict, List, Optional, Tuple, Union
from pyflink.dataframe.context import get_or_create_table_environment
-from pyflink.dataframe.dataframe import DataFrame
+from pyflink.dataframe.dataframe import DataFrame, _normalize_subset
from pyflink.dataframe.datatype import DataType
from pyflink.table import Schema, TableDescriptor
from pyflink.util.api_stability_decorators import PublicEvolving
-__all__ = ["read_generic"]
+__all__ = ["read_generic", "read_json", "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 _validate_connector(connector: str) -> None:
@@ -128,18 +374,43 @@ 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()
+ return DataFrame(table_environment.from_descriptor(descriptor))
+
+
@PublicEvolving()
def read_generic(
connector: str,
@@ -186,7 +457,7 @@ def read_generic(
.. versionadded:: 2.4.0
"""
- source_schema = _build_source_schema(schema, computed_columns, watermark)
- descriptor = _build_generic_descriptor(connector, options, source_schema)
- table_environment = get_or_create_table_environment()
- return DataFrame(table_environment.from_descriptor(descriptor))
+ return _read(
+ connector, schema=schema, options=options,
+ computed_columns=computed_columns, watermark=watermark,
+ )
diff --git a/flink-python/pyflink/dataframe/tests/test_filesystem_io.py
b/flink-python/pyflink/dataframe/tests/test_filesystem_io.py
new file mode 100644
index 00000000000..f1012ae9cd0
--- /dev/null
+++ b/flink-python/pyflink/dataframe/tests/test_filesystem_io.py
@@ -0,0 +1,860 @@
+################################################################################
+# 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 json
+import os
+import time
+import unittest
+from datetime import datetime
+from unittest.mock import patch
+from zoneinfo import ZoneInfo
+
+from py4j.protocol import Py4JJavaError
+
+import pyflink.dataframe as pf
+from pyflink.common import JobStatus
+from pyflink.dataframe import DataType
+from pyflink.dataframe.io import _build_filesystem_options
+from pyflink.datastream import RuntimeExecutionMode
+from pyflink.java_gateway import get_gateway
+from pyflink.table import EnvironmentSettings, StreamTableEnvironment,
TableDescriptor, TableResult
+from pyflink.testing.test_case_utils import PyFlinkDataFrameUTTestCase
+
+
+class FilesystemOptionsTests(unittest.TestCase):
+ def test_format_options_are_namespaced_without_mutating_input(self):
+ format_options = {
+ "ignore-parse-errors": "true",
+ "json.timestamp-format.standard": "ISO-8601",
+ }
+ original = dict(format_options)
+ options = _build_filesystem_options(
+ "/input", "json", extra_format_options=format_options
+ )
+ self.assertEqual(options, {
+ "path": "/input",
+ "format": "json",
+ "json.ignore-parse-errors": "true",
+ "json.timestamp-format.standard": "ISO-8601",
+ })
+ self.assertEqual(format_options, original)
+
+ def test_format_options_cannot_override_filesystem_options(self):
+ options = _build_filesystem_options(
+ "/input", "json", extra_format_options={"path": "/other",
"format": "parquet"}
+ )
+ self.assertEqual(options["path"], "/input")
+ self.assertEqual(options["format"], "json")
+
+ def test_rejects_invalid_format_options(self):
+ cases = [
+ ([], TypeError, "options must be a dict"),
+ ({1: "true"}, TypeError, "option keys must be strings"),
+ ({"": "true"}, ValueError, "option keys must not be empty"),
+ ({"ignore-parse-errors": True}, TypeError, "must have a string
value"),
+ ({"connector": "blackhole"}, ValueError, "connector argument"),
+ ({"ignore-parse-errors": "true", "json.ignore-parse-errors":
"false"},
+ ValueError, "duplicate format option"),
+ ]
+ for options, error, message in cases:
+ with self.subTest(options=options):
+ with self.assertRaisesRegex(error, message):
+ _build_filesystem_options(
+ "/input", "json", extra_format_options=options)
+
+ def test_connector_options_are_validated_without_mutating_input(self):
+ options = {"source.monitor-interval": "1s",
"source.report-statistics": "NONE"}
+ original = dict(options)
+ actual = _build_filesystem_options(
+ "/input", "json", connector_parameters={"source.monitor-interval":
"1s"},
+ extra_connector_options=options,
+ )
+ self.assertEqual(actual["source.report-statistics"], "NONE")
+ self.assertEqual(options, original)
+ for invalid in ({"path": "/other"}, {"format": "parquet"},
+ {"connector": "blackhole"},
{"json.ignore-parse-errors": "true"}):
+ with self.subTest(options=invalid):
+ with self.assertRaises(ValueError):
+ _build_filesystem_options(
+ "/input", "json", extra_connector_options=invalid)
+ for invalid in ([], {1: "value"}, {"source.monitor-interval": 1}):
+ with self.subTest(options=invalid):
+ with self.assertRaises(TypeError):
+ _build_filesystem_options(
+ "/input", "json", extra_connector_options=invalid)
+
+ def test_conflicting_explicit_options_are_rejected(self):
+ with self.assertRaisesRegex(ValueError, "conflicting
values.*source.monitor-interval"):
+ _build_filesystem_options(
+ "/input", "json",
connector_parameters={"source.monitor-interval": "1s"},
+ extra_connector_options={"source.monitor-interval": "2s"},
+ )
+ for prefix in ("", "parquet."):
+ with self.subTest(prefix=prefix):
+ options = {prefix + "compression": "GZIP"}
+ with self.assertRaisesRegex(ValueError, "conflicting
values.*parquet.compression"):
+ _build_filesystem_options(
+ "/input", "parquet", extra_format_options=options,
+ format_parameters={"compression": "SNAPPY"},
+ )
+ actual = _build_filesystem_options(
+ "/input", "parquet", extra_format_options=options,
+ format_parameters={"compression": "GZIP"},
+ )
+ self.assertEqual(actual["parquet.compression"], "GZIP")
+
+ def test_readers_reject_invalid_paths_and_option_types(self):
+ cases = [
+ ({"path": None}, TypeError, "path must be a string"),
+ ({"path": ""}, ValueError, "path must not be empty"),
+ ({"monitor_interval": 60}, TypeError, "must have a string value"),
+ ({"path_regex_pattern": 1}, TypeError, "must have a string value"),
+ ]
+ for reader in (pf.read_json, pf.read_parquet):
+ for overrides, error, message in cases:
+ with self.subTest(reader=reader.__name__, overrides=overrides):
+ arguments = {"path": "/input", "schema": {"id":
DataType.int64()}}
+ arguments.update(overrides)
+ with self.assertRaisesRegex(error, message):
+ reader(**arguments)
+
+ def test_writers_reject_invalid_arguments_before_execution(self):
+ dataframe = pf.DataFrame(None)
+ cases = [
+ ({"path": None}, TypeError, "path must be a string"),
+ ({"path": ""}, ValueError, "path must not be empty"),
+ ({"mode": None}, TypeError, "mode must be a string"),
+ ({"mode": "ignore"}, ValueError, "mode must be 'append' or
'overwrite'"),
+ ({"rolling_policy_file_size": 128}, TypeError, "must have a string
value"),
+ ({"rolling_policy_inactivity_interval": 1}, TypeError, "must have
a string value"),
+ ({"rolling_policy_check_interval": 5}, TypeError, "must have a
string value"),
+ ({"partition_commit_delay": 1}, TypeError, "must have a string
value"),
+ ({"sink_parallelism": True}, TypeError, "sink_parallelism must be
an int"),
+ ({"sink_parallelism": "1"}, TypeError, "sink_parallelism must be
an int"),
+ ({"sink_shuffle_by_partition": "true"}, TypeError,
+ "sink_shuffle_by_partition must be a bool"),
+ ({"auto_compaction": 1}, TypeError, "auto_compaction must be a
bool"),
+ ({"compaction_file_size": 128}, TypeError, "must have a string
value"),
+ ({"partition_by": [1]}, TypeError, "partition_by must be a
string"),
+ ({"partition_by": []}, ValueError, "partition_by must not be
empty"),
+ ({"partition_by": ""}, ValueError, "column names must not be
empty"),
+ ({"partition_by": ["id", "id"]}, ValueError, "must not be
duplicated"),
+ ]
+ for writer in (dataframe.write_json, dataframe.write_parquet):
+ for overrides, error, message in cases:
+ with self.subTest(writer=writer.__name__, overrides=overrides):
+ arguments = {"path": "/output"}
+ arguments.update(overrides)
+ with self.assertRaisesRegex(error, message):
+ writer(**arguments)
+ with self.assertRaisesRegex(TypeError, "must have a string value"):
+ dataframe.write_parquet("/output", compression=1)
+ with self.assertRaisesRegex(TypeError, "utc_timezone must be a bool"):
+ dataframe.write_parquet("/output", utc_timezone="true")
+ with self.assertRaisesRegex(TypeError, "ignore_parse_errors must be a
bool"):
+ pf.read_json("/input", schema={"id": DataType.int64()},
ignore_parse_errors=1)
+ for parameter in ("ignore_null_fields", "decimal_as_plain_number"):
+ with self.subTest(parameter=parameter):
+ with self.assertRaisesRegex(TypeError, parameter + " must be a
bool"):
+ dataframe.write_json("/output", **{parameter: "true"})
+
+
+class FilesystemIOTests(PyFlinkDataFrameUTTestCase):
+ _SCHEMA = {"id": DataType.int64(), "name": DataType.string()}
+
+ def test_readers_declare_computed_columns_and_watermarks(self):
+ for reader in (pf.read_json, pf.read_parquet):
+ with self.subTest(reader=reader.__name__):
+ dataframe = reader(
+ "/input", schema={"ts_millis": DataType.int64()},
+ computed_columns={"ts": "TO_TIMESTAMP_LTZ(ts_millis, 3)"},
+ watermark=("ts", "ts - INTERVAL '5' SECOND"),
+ )
+ resolved = dataframe.to_table().get_resolved_schema()
+ self.assertEqual(list(resolved.get_column_names()),
["ts_millis", "ts"])
+ self.assertEqual(len(resolved.get_watermark_specs()), 1)
+ physical = reader(
+ "/input", schema={"ts": DataType.timestamp(3)},
+ watermark=("ts", "ts - INTERVAL '5' SECOND"),
+ )
+ self.assertEqual(len(physical.to_table().get_resolved_schema()
+ .get_watermark_specs()), 1)
+
+ def test_readers_forward_connector_and_format_parameters(self):
+ cases = [
+ (pf.read_json, {"ignore_parse_errors": True,
"fail_on_missing_field": False,
+ "timestamp_format": "ISO-8601"},
+ {"json.ignore-parse-errors": "true",
"json.fail-on-missing-field": "false",
+ "json.timestamp-format.standard": "ISO-8601"}),
+ (pf.read_parquet, {"utc_timezone": True}, {"parquet.utc-timezone":
"true"}),
+ ]
+ for reader, parameters, expected in cases:
+ with self.subTest(reader=reader.__name__):
+ with patch.object(self.t_env, "from_descriptor",
+ wraps=self.t_env.from_descriptor) as
from_descriptor:
+ reader("/input", schema=self._SCHEMA, **parameters,
+ connector_options={"source.report-statistics":
"NONE"})
+ options = dict(from_descriptor.call_args.args[0].get_options())
+ self.assertEqual(options["source.report-statistics"], "NONE")
+ for key, value in expected.items():
+ self.assertEqual(options[key], value)
+
+ def test_invalid_partition_columns_are_rejected_by_the_table_api(self):
+ for reader in (pf.read_json, pf.read_parquet):
+ with self.subTest(reader=reader.__name__):
+ with self.assertRaisesRegex(Py4JJavaError, "missing"):
+ reader("/input", schema=self._SCHEMA,
partition_by="missing")
+ dataframe = pf.from_records([(1,)], schema=["id"])
+ for writer in (dataframe.write_json, dataframe.write_parquet):
+ with self.subTest(writer=writer.__name__):
+ with self.assertRaisesRegex(Py4JJavaError, "missing"):
+ writer("/output", partition_by="missing")
+
+ def
test_writers_accept_raw_options_without_overriding_them_with_defaults(self):
+ dataframe = pf.from_records([(1,)], schema=["id"])
+ with patch.object(dataframe._table, "execute_insert") as
execute_insert:
+ dataframe.write_parquet(
+ "/output", format_options={"compression": "GZIP"},
+ connector_options={"sink.rolling-policy.file-size": "64mb"},
+ )
+ options = dict(execute_insert.call_args.args[0].get_options())
+ self.assertEqual(options["parquet.compression"], "GZIP")
+ self.assertEqual(options["sink.rolling-policy.file-size"], "64mb")
+
+ def test_readers_build_filesystem_descriptors(self):
+ for file_format, reader in [("json", pf.read_json), ("parquet",
pf.read_parquet)]:
+ for continuous in (False, True):
+ with self.subTest(file_format=file_format,
continuous=continuous):
+ arguments = {}
+ expected = {"connector": "filesystem", "path": "/input",
+ "format": file_format}
+ if continuous:
+ arguments = {"monitor_interval": "60s",
"path_regex_pattern": ".*data.*"}
+ expected.update({"source.monitor-interval": "60s",
+ "source.path.regex-pattern":
".*data.*"})
+ with patch.object(self.t_env, "from_descriptor",
+ wraps=self.t_env.from_descriptor) as
from_descriptor:
+ dataframe = reader("/input", schema=self._SCHEMA,
**arguments)
+ descriptor = from_descriptor.call_args.args[0]
+ self.assertEqual(dict(descriptor.get_options()), expected)
+ self.assert_dataframe_schema(dataframe, ["id", "name"])
+
+ def test_json_reader_forwards_format_options(self):
+ with patch.object(self.t_env, "from_descriptor",
+ wraps=self.t_env.from_descriptor) as from_descriptor:
+ pf.read_json("/input", schema=self._SCHEMA,
+ format_options={"ignore-parse-errors": "true"})
+ options = from_descriptor.call_args.args[0].get_options()
+ self.assertEqual(options.get("json.ignore-parse-errors"), "true")
+
+ def test_writers_build_descriptors_and_apply_write_mode(self):
+ dataframe = pf.from_records([(1, "a")], schema=["id", "name"])
+ for file_format in ("json", "parquet"):
+ writer = getattr(dataframe, "write_" + file_format)
+ for mode in (None, "append", "overwrite"):
+ with self.subTest(file_format=file_format, mode=mode):
+ arguments = {} if mode is None else {"mode": mode}
+ with patch.object(dataframe._table, "execute_insert") as
execute_insert:
+ self.assertIsNone(writer("/output", **arguments))
+ descriptor = execute_insert.call_args.args[0]
+ expected = {
+ "connector": "filesystem", "path": "/output",
"format": file_format,
+ }
+ self.assertEqual(dict(descriptor.get_options()), expected)
+ self.assertIsNone(descriptor.get_schema())
+ self.assertEqual(execute_insert.call_args.kwargs,
+ {"overwrite": mode == "overwrite"})
+
+ def test_writers_forward_compaction_and_shuffle_options(self):
+ dataframe = pf.from_records([(1, "a")], schema=["id", "name"])
+ for writer in (dataframe.write_json, dataframe.write_parquet):
+ for enabled in (False, True):
+ with self.subTest(writer=writer.__name__, enabled=enabled):
+ raw_options = {
+ "sink.shuffle-by-partition.enable":
str(enabled).lower(),
+ "auto-compaction": str(enabled).lower(),
+ "compaction.file-size": "64mb",
+ }
+ with patch.object(dataframe._table, "execute_insert") as
execute_insert:
+ writer("/output", partition_by="name",
+ sink_shuffle_by_partition=enabled,
auto_compaction=enabled,
+ compaction_file_size="64mb",
connector_options=raw_options)
+ options =
dict(execute_insert.call_args.args[0].get_options())
+ for key, value in raw_options.items():
+ self.assertEqual(options[key], value)
+ self.assertNotIn("sink.rolling-policy.file-size", options)
+
+ def test_json_writer_forwards_encoding_options(self):
+ dataframe = pf.from_records([(1,)], schema=["id"])
+ for enabled in (False, True):
+ with self.subTest(enabled=enabled):
+ raw_options = {
+ "encode.ignore-null-fields": str(enabled).lower(),
+ "json.encode.decimal-as-plain-number":
str(enabled).lower(),
+ }
+ with patch.object(dataframe._table, "execute_insert") as
execute_insert:
+ dataframe.write_json(
+ "/output", ignore_null_fields=enabled,
decimal_as_plain_number=enabled,
+ format_options=raw_options,
+ )
+ options = dict(execute_insert.call_args.args[0].get_options())
+ self.assertEqual(options["json.encode.ignore-null-fields"],
str(enabled).lower())
+
self.assertEqual(options["json.encode.decimal-as-plain-number"],
+ str(enabled).lower())
+
+ def
test_writers_reject_conflicting_convenience_options_before_execution(self):
+ dataframe = pf.from_records([(1,)], schema=["id"])
+ cases = [
+ {"sink_shuffle_by_partition": True,
+ "connector_options": {"sink.shuffle-by-partition.enable":
"false"}},
+ {"auto_compaction": True, "connector_options": {"auto-compaction":
"false"}},
+ {"compaction_file_size": "64mb",
+ "connector_options": {"compaction.file-size": "128mb"}},
+ ]
+ for writer in (dataframe.write_json, dataframe.write_parquet):
+ for arguments in cases:
+ with self.subTest(writer=writer.__name__, arguments=arguments):
+ with patch.object(dataframe._table, "execute_insert") as
execute_insert:
+ with self.assertRaisesRegex(ValueError, "conflicting
values"):
+ writer("/output", **arguments)
+ execute_insert.assert_not_called()
+ for parameter, option in [("ignore_null_fields",
"encode.ignore-null-fields"),
+ ("decimal_as_plain_number",
"encode.decimal-as-plain-number")]:
+ with self.subTest(parameter=parameter):
+ with patch.object(dataframe._table, "execute_insert") as
execute_insert:
+ with self.assertRaisesRegex(ValueError, "conflicting
values"):
+ dataframe.write_json(
+ "/output", **{parameter: True},
format_options={option: "false"})
+ execute_insert.assert_not_called()
+
+ def test_writers_forward_custom_sink_options_and_wait_locally(self):
+ dataframe = pf.from_records([(1,)], schema=["id"])
+ for file_format in ("json", "parquet"):
+ for target in ("local", "minicluster", "remote"):
+ with self.subTest(file_format=file_format, target=target):
+ self.t_env.get_config().set("execution.target", target)
+ arguments = ({"compression": "GZIP"} if file_format ==
"parquet" else
+ {"timestamp_format": "ISO-8601"})
+ with patch.object(dataframe._table, "execute_insert") as
execute_insert:
+ getattr(dataframe, "write_" + file_format)(
+ "/output", mode="append",
rolling_policy_file_size="64mb",
+ rolling_policy_rollover_interval="10min",
+ rolling_policy_inactivity_interval="2min",
sink_parallelism=2,
+ rolling_policy_check_interval="1min",
+ partition_commit_trigger="partition-time",
partition_commit_delay="1h",
+ partition_commit_policy_kind="success-file",
**arguments)
+ options =
dict(execute_insert.call_args.args[0].get_options())
+ self.assertEqual(options["sink.rolling-policy.file-size"],
"64mb")
+
self.assertEqual(options["sink.rolling-policy.rollover-interval"], "10min")
+
self.assertEqual(options["sink.rolling-policy.inactivity-interval"], "2min")
+ self.assertEqual(options["sink.parallelism"], "2")
+
self.assertEqual(options["sink.rolling-policy.check-interval"], "1min")
+ self.assertEqual(options["sink.partition-commit.trigger"],
"partition-time")
+ self.assertEqual(options["sink.partition-commit.delay"],
"1h")
+
self.assertEqual(options["sink.partition-commit.policy.kind"], "success-file")
+ if file_format == "parquet":
+ self.assertEqual(options["parquet.compression"],
"GZIP")
+ else:
+
self.assertEqual(options["json.timestamp-format.standard"], "ISO-8601")
+ if target in ("local", "minicluster"):
+
execute_insert.return_value.wait.assert_called_once_with()
+ else:
+ execute_insert.return_value.wait.assert_not_called()
+
+
+class FilesystemIOITTests(PyFlinkDataFrameUTTestCase):
+ _SCHEMA = {"id": DataType.int64(), "name": DataType.string()}
+
+ def setUp(self):
+ super().setUp()
+ self.env.set_runtime_mode(RuntimeExecutionMode.BATCH)
+ self.env.set_parallelism(1)
+ self.t_env = StreamTableEnvironment.create(
+ self.env, environment_settings=EnvironmentSettings.in_batch_mode()
+ )
+ pf.set_table_environment(self.t_env)
+
+ def test_json_round_trip_overwrite_and_append(self):
+ self._check_round_trip_overwrite_and_append("json")
+
+ def test_json_partitioned_round_trip_and_overwrite(self):
+ self._check_partitioned_round_trip_and_overwrite("json")
+
+ @unittest.skipIf(os.environ.get('HADOOP_CLASSPATH') is None,
+ 'Hadoop libraries are required for Parquet format tests')
+ def test_parquet_partitioned_round_trip_and_overwrite(self):
+ self._check_partitioned_round_trip_and_overwrite("parquet")
+
+ def _check_partitioned_round_trip_and_overwrite(self, file_format):
+ output_path = os.path.join(self.tempdir, "partitioned-" + file_format)
+ reader = getattr(pf, "read_" + file_format)
+ original = pf.from_records([(1, "a"), (2, "b")], schema=["id", "name"])
+ getattr(original, "write_" + file_format)(output_path,
partition_by="name")
+ self.assertTrue(os.path.isdir(os.path.join(output_path, "name=a")))
+ self.assertTrue(os.path.isdir(os.path.join(output_path, "name=b")))
+ rows = reader(output_path, schema=self._SCHEMA,
partition_by="name").collect()
+ self.assertEqual(sorted(tuple(row) for row in rows), [(1, "a"), (2,
"b")])
+
+ replacement = pf.from_records([(3, "a")], schema=["id", "name"])
+ getattr(replacement, "write_" + file_format)(
+ output_path, partition_by=["name"], mode="overwrite")
+ rows = reader(output_path, schema=self._SCHEMA,
partition_by=["name"]).collect()
+ self.assertEqual(sorted(tuple(row) for row in rows), [(2, "b"), (3,
"a")])
+
+ additional = pf.from_records([(4, "a/b=c")], schema=["id", "name"])
+ getattr(additional, "write_" + file_format)(
+ output_path, partition_by="name")
+ rows = reader(output_path, schema=self._SCHEMA,
partition_by="name").collect()
+ self.assertEqual(sorted(tuple(row) for row in rows),
+ [(2, "b"), (3, "a"), (4, "a/b=c")])
+
+ default_path = os.path.join(self.tempdir, "default-partition-" +
file_format)
+ default_values = pf.from_records([(5, None), (6, "")], schema=["id",
"name"])
+ getattr(default_values, "write_" + file_format)(
+ default_path, partition_by="name",
+ connector_options={"partition.default-name": "unspecified"},
+ )
+ self.assertEqual([name for name in os.listdir(default_path) if not
name.startswith(".")],
+ ["name=unspecified"])
+
+ def test_partition_column_order_and_payload(self):
+ output_path = os.path.join(self.tempdir, "partition-order")
+ dataframe = pf.from_records([(1, "a", "b")], schema=["id", "first",
"second"])
+ dataframe.write_json(output_path, partition_by=["second", "first"])
+ partition_path = os.path.join(output_path, "second=b", "first=a")
+ self.assertTrue(os.path.isdir(partition_path))
+ records = []
+ for filename in os.listdir(partition_path):
+ if filename.startswith("part-"):
+ with open(os.path.join(partition_path, filename),
encoding="utf-8") as output:
+ records.extend(json.loads(line) for line in output)
+ self.assertEqual(records, [{"id": 1}])
+
+ def test_json_continuous_discovery_and_partition_commit(self):
+ self._check_continuous_discovery_and_partition_commit("json")
+
+ @unittest.skipIf(os.environ.get('HADOOP_CLASSPATH') is None,
+ 'Hadoop libraries are required for Parquet format tests')
+ def test_parquet_reader_discovers_new_files(self):
+ input_path = os.path.join(self.tempdir, "parquet-source")
+ output_path = os.path.join(self.tempdir, "json-sink")
+ os.makedirs(input_path)
+ staged_files = []
+ expected_rows = [(1, "first"), (2, "second")]
+ for row in expected_rows:
+ staging_path = os.path.join(self.tempdir, "parquet-staging-" +
str(row[0]))
+ pf.from_records([row], schema=["id",
"name"]).write_parquet(staging_path)
+ files = self._finished_files(staging_path)
+ self.assertEqual(len(files), 1)
+ staged_files.append(os.path.join(staging_path, files[0]))
+
+ # Publish only finished files so discovery cannot race with Parquet
footer writes.
+ os.replace(staged_files[0], os.path.join(input_path, "first.parquet"))
+ self.env.set_runtime_mode(RuntimeExecutionMode.STREAMING)
+ self.env.enable_checkpointing(100)
+ self.t_env = StreamTableEnvironment.create(self.env)
+ pf.set_table_environment(self.t_env)
+ dataframe = pf.read_parquet(input_path, schema=self._SCHEMA,
monitor_interval="50ms")
+ sink = (TableDescriptor.for_connector("filesystem")
+ .option("path", output_path)
+ .option("format", "json")
+ .option("sink.rolling-policy.rollover-interval", "100ms")
+ .option("sink.rolling-policy.check-interval", "50ms")
+ .build())
+
+ def read_finished_rows():
+ rows = []
+ for filename in self._finished_files(output_path):
+ with open(os.path.join(output_path, filename),
encoding="utf-8") as output:
+ for line in output:
+ record = json.loads(line)
+ rows.append((record["id"], record["name"]))
+ return rows
+
+ result = dataframe.to_table().execute_insert(sink)
+ job_client = result.get_job_client()
+ execution_result = job_client.get_job_execution_result()
+ try:
+ self._wait_for(lambda: expected_rows[0] in read_finished_rows(),
+ "initial Parquet file was not read")
+ self.assertFalse(execution_result.done(), "Parquet source stopped
after initial files")
+ os.replace(staged_files[1], os.path.join(input_path,
"second.parquet"))
+ self._wait_for(lambda: expected_rows[1] in read_finished_rows(),
+ "new Parquet file was not discovered")
+ self.assertFalse(execution_result.done(), "continuous Parquet
source terminated")
+ finally:
+ self._cancel_job_and_wait(job_client)
+ self.assertCountEqual(read_finished_rows(), expected_rows)
+
+ def test_json_partition_time_commit(self):
+ self._check_continuous_discovery_and_partition_commit("json",
"partition-time")
+
+ def test_json_partition_time_commit_with_local_timezone(self):
+ self._check_continuous_discovery_and_partition_commit(
+ "json", "partition-time", "Asia/Shanghai")
+
+ @unittest.skipIf(os.environ.get('HADOOP_CLASSPATH') is None,
+ 'Hadoop libraries are required for Parquet format tests')
+ def test_parquet_continuous_discovery_and_partition_commit(self):
+ self._check_continuous_discovery_and_partition_commit("parquet")
+
+ def test_json_streaming_compaction(self):
+ self._check_continuous_discovery_and_partition_commit("json",
auto_compaction=True)
+
+ @unittest.skipIf(os.environ.get('HADOOP_CLASSPATH') is None,
+ 'Hadoop libraries are required for Parquet format tests')
+ def test_parquet_streaming_compaction(self):
+ self._check_continuous_discovery_and_partition_commit("parquet",
auto_compaction=True)
+
+ def _check_continuous_discovery_and_partition_commit(
+ self, file_format, partition_commit_trigger="process-time",
local_timezone="UTC",
+ auto_compaction=None,
+ ):
+ self.env.set_runtime_mode(RuntimeExecutionMode.STREAMING)
+ self.env.enable_checkpointing(100)
+ self.t_env = StreamTableEnvironment.create(self.env)
+ self.t_env.get_config().set_local_timezone(local_timezone)
+ pf.set_table_environment(self.t_env)
+ suffix = self._testMethodName
+ input_path = os.path.join(self.tempdir, "continuous-input-" + suffix)
+ output_path = os.path.join(self.tempdir, "continuous-output-" + suffix)
+ os.makedirs(input_path)
+ partition_value = "2026-01-01"
+ # Keep the watermark near the partition boundary to expose time zone
mismatches.
+ timestamp = datetime(2026, 1, 1, 0, 1, tzinfo=ZoneInfo(local_timezone))
+ record = {"id": 1, "name": partition_value,
+ "ts_millis": int(timestamp.timestamp() * 1000)}
+ self._publish_json_file(input_path, "first.json", record)
+ dataframe = pf.read_json(
+ input_path, schema={**self._SCHEMA, "ts_millis": DataType.int64()},
+ monitor_interval="50ms",
+ computed_columns={"ts": "TO_TIMESTAMP_LTZ(ts_millis, 3)"},
+ watermark=("ts", "ts - INTERVAL '1' SECOND"),
+ )
+ results = []
+ execute_insert = dataframe._table.execute_insert
+
+ def capture_result(*args, **kwargs):
+ result = execute_insert(*args, **kwargs)
+ results.append(result)
+ return result
+
+ # Keep the public writer's actual execution, but manage this unbounded
job in the test.
+ with patch.object(dataframe._table, "execute_insert",
side_effect=capture_result), \
+ patch.object(TableResult, "wait"):
+ getattr(dataframe, "write_" + file_format)(
+ output_path, partition_by="name",
+ auto_compaction=auto_compaction,
+ compaction_file_size="1mb" if auto_compaction else None,
+ sink_shuffle_by_partition=True if auto_compaction else None,
+ rolling_policy_rollover_interval="100ms",
rolling_policy_check_interval="50ms",
+ partition_commit_policy_kind="success-file",
+ partition_commit_trigger=partition_commit_trigger,
+ connector_options={
+ "sink.partition-commit.success-file.name": "_READY",
+ "partition.time-extractor.timestamp-pattern": "$name
00:00:00",
+ "sink.partition-commit.watermark-time-zone":
local_timezone,
+ },
+ )
+ job_client = results[0].get_job_client()
+ partition_path = os.path.join(output_path, "name=" + partition_value)
+ try:
+ self._wait_for(
+ lambda: os.path.exists(os.path.join(partition_path, "_READY"))
+ and len(self._finished_files(partition_path)) >= 1,
+ "first checkpoint did not finish and commit the partition",
+ )
+ self._publish_json_file(input_path, "second.json", {**record,
"id": 2})
+ self._wait_for(
+ lambda: len(self._finished_files(partition_path)) >= 2,
+ "newly discovered file was not committed by a later
checkpoint",
+ )
+ if auto_compaction:
+ self.assertTrue(all(name.startswith("compacted-")
+ for name in
self._finished_files(partition_path)))
+ self.assertEqual(job_client.get_job_status().result(),
JobStatus.RUNNING)
+ finally:
+ self._cancel_job_and_wait(job_client)
+
+ self.env.set_runtime_mode(RuntimeExecutionMode.BATCH)
+ self.t_env = StreamTableEnvironment.create(
+ self.env, environment_settings=EnvironmentSettings.in_batch_mode())
+ pf.set_table_environment(self.t_env)
+ rows = getattr(pf, "read_" + file_format)(
+ output_path, schema=self._SCHEMA, partition_by="name").collect()
+ self.assertEqual(sorted(tuple(row) for row in rows),
+ [(1, partition_value), (2, partition_value)])
+
+ @staticmethod
+ def _publish_json_file(directory, filename, record):
+ temporary_path = os.path.join(directory, ".pending")
+ with open(temporary_path, "w", encoding="utf-8") as source:
+ source.write(json.dumps(record) + "\n")
+ os.replace(temporary_path, os.path.join(directory, filename))
+
+ @staticmethod
+ def _finished_files(directory):
+ if not os.path.isdir(directory):
+ return []
+ return [name for name in os.listdir(directory)
+ if name.startswith(("part-", "compacted-part-"))]
+
+ def _cancel_job_and_wait(self, job_client):
+ execution_result = job_client.get_job_execution_result()
+ # A finished job may have already shut down its MiniCluster.
+ if not execution_result.done():
+ cancellation = job_client.cancel()
+ self._wait_for(cancellation.done, "streaming job cancellation was
not acknowledged")
+ cancellation.result()
+ # Cancellation acknowledges the request before the job has fully
terminated.
+ self._wait_for(execution_result.done, "streaming job did not
terminate")
+
+ def _wait_for(self, predicate, message):
+ deadline = time.monotonic() + 60
+ while not predicate():
+ if time.monotonic() >= deadline:
+ self.fail(message)
+ time.sleep(0.05)
+
+ @unittest.skipIf(os.environ.get('HADOOP_CLASSPATH') is None,
+ 'Hadoop libraries are required for Parquet format tests')
+ def test_parquet_round_trip_overwrite_and_append(self):
+ self._check_round_trip_overwrite_and_append("parquet")
+
+ def _check_round_trip_overwrite_and_append(self, file_format):
+ output_path = os.path.join(self.tempdir, file_format)
+ reader = getattr(pf, "read_" + file_format)
+ original = pf.from_records([(1, "a"), (2, None)], schema=["id",
"name"])
+ getattr(original, "write_" + file_format)(output_path)
+ if file_format == "parquet":
+ self._assert_parquet_compression(output_path, "SNAPPY")
+ rows = reader(output_path, schema=self._SCHEMA).collect()
+ self.assertEqual(sorted(tuple(row) for row in rows), [(1, "a"), (2,
None)])
+
+ replacement = pf.from_records([(3, "c")], schema=["id", "name"])
+ options = {"compression": "GZIP"} if file_format == "parquet" else {}
+ getattr(replacement, "write_" + file_format)(output_path,
mode="overwrite", **options)
+ if file_format == "parquet":
+ self._assert_parquet_compression(output_path, "GZIP")
+ rows = reader(output_path, schema=self._SCHEMA).collect()
+ self.assertEqual([tuple(row) for row in rows], [(3, "c")])
+
+ getattr(original, "write_" + file_format)(output_path)
+ rows = reader(output_path, schema=self._SCHEMA).collect()
+ self.assertEqual(sorted(tuple(row) for row in rows),
+ [(1, "a"), (2, None), (3, "c")])
+
+ def _assert_parquet_compression(self, output_path, expected_codec):
+ jvm = get_gateway().jvm
+ hadoop_conf = jvm.org.apache.hadoop.conf.Configuration()
+ codecs = set()
+ for filename in os.listdir(output_path):
+ if filename.startswith("part-"):
+ path = jvm.org.apache.hadoop.fs.Path(os.path.join(output_path,
filename))
+ footer =
jvm.org.apache.parquet.hadoop.ParquetFileReader.readFooter(
+ hadoop_conf, path
+ )
+ for block in footer.getBlocks():
+ codecs.update(column.getCodec().name() for column in
block.getColumns())
+ self.assertEqual(codecs, {expected_codec})
+
+ @unittest.skipIf(os.environ.get('HADOOP_CLASSPATH') is None,
+ 'Hadoop libraries are required for Parquet format tests')
+ def test_parquet_timestamp_encoding_options(self):
+ timestamp = datetime(1970, 1, 1, 0, 0, 1, 123456)
+ dataframe = pf.sql("SELECT TIMESTAMP '1970-01-01 00:00:01.123456' AS
ts")
+ jvm = get_gateway().jvm
+ hadoop_conf = jvm.org.apache.hadoop.conf.Configuration()
+ cases = [(None, None), ("millis", 1123), ("micros", 1123456),
("nanos", 1123456000)]
+ for unit, encoded_timestamp in cases:
+ with self.subTest(unit=unit):
+ output_path = os.path.join(self.tempdir, "parquet-timestamp-"
+ str(unit))
+ options = {} if unit is None else {
+ "write.int64.timestamp": "true", "timestamp.time.unit":
unit,
+ }
+ dataframe.write_parquet(output_path, utc_timezone=True,
format_options=options)
+ files = self._finished_files(output_path)
+ self.assertEqual(len(files), 1)
+ path = jvm.org.apache.hadoop.fs.Path(os.path.join(output_path,
files[0]))
+ footer =
jvm.org.apache.parquet.hadoop.ParquetFileReader.readFooter(
+ hadoop_conf, path)
+ column_type =
footer.getFileMetaData().getSchema().getType("ts").asPrimitiveType()
+ if unit is None:
+
self.assertEqual(column_type.getPrimitiveTypeName().name(), "INT96")
+ else:
+
self.assertEqual(column_type.getPrimitiveTypeName().name(), "INT64")
+
self.assertEqual(column_type.getLogicalTypeAnnotation().getUnit().name(),
+ unit.upper())
+ reader =
(jvm.org.apache.parquet.hadoop.ParquetReader.builder(
+
jvm.org.apache.parquet.hadoop.example.GroupReadSupport(), path)
+ .withConf(hadoop_conf).build())
+ try:
+ self.assertEqual(reader.read().getLong("ts", 0),
encoded_timestamp)
+ self.assertIsNone(reader.read())
+ finally:
+ reader.close()
+ restored = pf.read_parquet(
+ output_path, schema={"ts": DataType.timestamp(6)},
utc_timezone=True)
+ expected = timestamp.replace(microsecond=123000) if unit ==
"millis" else timestamp
+ view_name = "parquet_timestamps_" + str(unit)
+ self.t_env.create_temporary_view(view_name,
restored.to_table())
+ # Compare in SQL so precision checks do not depend on Python
timestamp conversion.
+ rows = pf.sql(
+ "SELECT ts = TIMESTAMP '" + expected.isoformat(sep=" ")
+ + "' AS is_equal FROM " + view_name).collect()
+ self.assertEqual([tuple(row) for row in rows], [(True,)])
+
+ def test_json_streaming_append_and_overwrite_rejection(self):
+ self._check_streaming_append_and_overwrite_rejection("json")
+
+ @unittest.skipIf(os.environ.get('HADOOP_CLASSPATH') is None,
+ 'Hadoop libraries are required for Parquet format tests')
+ def test_parquet_streaming_append_and_overwrite_rejection(self):
+ self._check_streaming_append_and_overwrite_rejection("parquet")
+
+ def _check_streaming_append_and_overwrite_rejection(self, file_format):
+ self.env.set_runtime_mode(RuntimeExecutionMode.STREAMING)
+ self.t_env = StreamTableEnvironment.create(self.env)
+ pf.set_table_environment(self.t_env)
+ dataframe = pf.from_records([(1, "a")], schema=["id", "name"])
+ output_path = os.path.join(self.tempdir, "stream-" + file_format)
+ writer = getattr(dataframe, "write_" + file_format)
+ with self.assertRaisesRegex(Py4JJavaError, "Streaming mode not support
overwrite"):
+ writer(output_path, mode="overwrite")
+ self.assertFalse(os.path.exists(output_path))
+ writer(output_path)
+ writer(output_path, mode="append")
+ rows = getattr(pf, "read_" + file_format)(
+ output_path, schema=self._SCHEMA
+ ).collect()
+ self.assertEqual([tuple(row) for row in rows], [(1, "a"), (1, "a")])
+
+ def test_json_encoding_options(self):
+ dataframe = pf.sql(
+ "SELECT CAST(NULL AS STRING) AS name, CAST(0.0000001 AS
DECIMAL(10, 7)) AS amount")
+ for enabled in (None, False, True):
+ with self.subTest(enabled=enabled):
+ output_path = os.path.join(self.tempdir, "json-encoding-" +
str(enabled))
+ dataframe.write_json(
+ output_path, ignore_null_fields=enabled,
decimal_as_plain_number=enabled)
+ records = []
+ for filename in os.listdir(output_path):
+ if filename.startswith("part-"):
+ with open(os.path.join(output_path, filename),
encoding="utf-8") as output:
+ for line in output:
+
self.assertIsInstance(json.loads(line)["amount"], float)
+ records.append(json.loads(line,
parse_float=str))
+ expected = {"amount": "0.0000001"} if enabled else {"name":
None, "amount": "1E-7"}
+ self.assertEqual(records, [expected])
+
+ def test_json_map_null_keys_are_independent_of_null_fields(self):
+ dataframe = pf.sql(
+ "SELECT CAST(NULL AS STRING) AS name, "
+ "MAP[CAST(NULL AS STRING), 1, 'known', 2] AS attributes")
+ cases = [
+ ({"map-null-key.mode": "DROP"}, {"attributes": {"known": 2}}),
+ ({"map-null-key.mode": "LITERAL", "map-null-key.literal":
"missing"},
+ {"attributes": {"missing": 1, "known": 2}}),
+ ]
+ for options, expected in cases:
+ with self.subTest(options=options):
+ output_path = os.path.join(self.tempdir, "json-map-" +
options["map-null-key.mode"])
+ dataframe.write_json(output_path, ignore_null_fields=True,
format_options=options)
+ records = []
+ for filename in self._finished_files(output_path):
+ with open(os.path.join(output_path, filename),
encoding="utf-8") as output:
+ records.extend(json.loads(line) for line in output)
+ self.assertEqual(records, [expected])
+
+ def test_json_format_options_and_path_filter(self):
+ input_path = os.path.join(self.tempdir, "json-input")
+ os.makedirs(input_path)
+ with open(os.path.join(input_path, "data.json"), "w",
encoding="utf-8") as source:
+
source.write('{"id":1,"name":"a"}\nnot-json\n{"id":2,"name":null}\n')
+ with open(os.path.join(input_path, "excluded.json"), "w",
encoding="utf-8") as source:
+ source.write('{"id":99,"name":"excluded"}\n')
+ rows = pf.read_json(
+ input_path, schema=self._SCHEMA,
path_regex_pattern=".*data[.]json",
+ format_options={"ignore-parse-errors": "true"},
+ ).collect()
+ self.assertEqual(sorted(tuple(row) for row in rows), [(1, "a"), (2,
None)])
+ rows = pf.read_json(
+ input_path, schema=self._SCHEMA, path_regex_pattern="data[.]json",
+ ).collect()
+ self.assertEqual(rows, [])
+
+ def test_json_continuous_source_is_rejected_in_batch_mode(self):
+ self._check_continuous_source_is_rejected_in_batch_mode(pf.read_json)
+
+ @unittest.skipIf(os.environ.get('HADOOP_CLASSPATH') is None,
+ 'Hadoop libraries are required for Parquet format tests')
+ def test_parquet_continuous_source_is_rejected_in_batch_mode(self):
+
self._check_continuous_source_is_rejected_in_batch_mode(pf.read_parquet)
+
+ def _check_continuous_source_is_rejected_in_batch_mode(self, reader):
+ input_path = os.path.join(self.tempdir, self._testMethodName)
+ os.makedirs(input_path)
+ dataframe = reader(input_path, schema=self._SCHEMA,
monitor_interval="1s")
+ with self.assertRaisesRegex(Py4JJavaError, "[Uu]nbounded"):
+ dataframe.to_table().explain()
+
+ def test_computed_watermark_supports_event_time_windows(self):
+ input_path = os.path.join(self.tempdir, "event-time.json")
+ with open(input_path, "w", encoding="utf-8") as source:
+ source.write('{"ts_millis":1000}\n{"ts_millis":2000}\n')
+ self.env.set_runtime_mode(RuntimeExecutionMode.STREAMING)
+ self.t_env = StreamTableEnvironment.create(self.env)
+ pf.set_table_environment(self.t_env)
+ dataframe = pf.read_json(
+ input_path, schema={"ts_millis": DataType.int64()},
+ computed_columns={"ts": "TO_TIMESTAMP_LTZ(ts_millis, 3)"},
+ watermark=("ts", "ts - INTERVAL '1' SECOND"),
+ )
+ self.t_env.create_temporary_view("events", dataframe.to_table())
+ result = pf.sql(
+ "SELECT COUNT(*) AS cnt FROM TABLE("
+ "TUMBLE(TABLE events, DESCRIPTOR(ts), INTERVAL '5' SECOND)) "
+ "GROUP BY window_start, window_end"
+ ).collect()
+ self.assertEqual([tuple(row) for row in result], [(2,)])
+
+ def test_json_timestamp_format_options_round_trip(self):
+ output_path = os.path.join(self.tempdir, "json-timestamp")
+ timestamp = datetime(2026, 1, 2, 3, 4, 5)
+ dataframe = pf.sql("SELECT TIMESTAMP '2026-01-02 03:04:05' AS ts")
+ dataframe.write_json(
+ output_path, timestamp_format="ISO-8601",
+ format_options={"timestamp-format.standard": "ISO-8601"},
+ )
+ records = []
+ for filename in os.listdir(output_path):
+ if filename.startswith("part-"):
+ with open(os.path.join(output_path, filename),
encoding="utf-8") as output:
+ records.extend(json.loads(line) for line in output)
+ self.assertEqual(records, [{"ts": timestamp.isoformat()}])
+
+ rows = pf.read_json(
+ output_path, schema={"ts": DataType.timestamp()},
+ timestamp_format="ISO-8601",
+ format_options={"json.timestamp-format.standard": "ISO-8601"},
+ ).collect()
+ self.assertEqual([tuple(row) for row in rows], [(timestamp,)])
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/flink-python/pyflink/dataframe/tests/test_io.py
b/flink-python/pyflink/dataframe/tests/test_io.py
index 708f3aa3439..ef57cfde616 100644
--- a/flink-python/pyflink/dataframe/tests/test_io.py
+++ b/flink-python/pyflink/dataframe/tests/test_io.py
@@ -221,6 +221,7 @@ class GenericIOTests(PyFlinkDataFrameUTTestCase):
self.assertIsNone(descriptor.get_schema())
self.assertEqual(descriptor.get_options().get("connector"),
"blackhole")
self.assertEqual(descriptor.get_options().get("sink.parallelism"), "1")
+ self.assertEqual(execute_insert.call_args.kwargs, {"overwrite": False})
def test_write_generic_waits_for_local_and_minicluster_execution(self):
dataframe = pf.from_records([(1,)], schema=["id"])