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 e63885d9db3 [FLINK-40430][python] Add configuration support to
DataFrame API
e63885d9db3 is described below
commit e63885d9db398684d4a935a395ed3c59246e1c94
Author: Federico Dolce <[email protected]>
AuthorDate: Tue Sep 1 16:01:32 2026 +0200
[FLINK-40430][python] Add configuration support to DataFrame API
This closes #29088.
---
.../docs/reference/pyflink.dataframe/config.rst | 51 ++++++
.../docs/reference/pyflink.dataframe/index.rst | 1 +
flink-python/pyflink/dataframe/__init__.py | 2 +
flink-python/pyflink/dataframe/context.py | 66 +++++++-
flink-python/pyflink/dataframe/dataframe_config.py | 148 ++++++++++++++++
.../pyflink/dataframe/tests/test_config.py | 187 +++++++++++++++++++++
6 files changed, 449 insertions(+), 6 deletions(-)
diff --git a/flink-python/docs/reference/pyflink.dataframe/config.rst
b/flink-python/docs/reference/pyflink.dataframe/config.rst
new file mode 100644
index 00000000000..36730b1b597
--- /dev/null
+++ b/flink-python/docs/reference/pyflink.dataframe/config.rst
@@ -0,0 +1,51 @@
+..
################################################################################
+ 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.
+
################################################################################
+
+=============
+Configuration
+=============
+
+A unified entry point for Flink configuration. The module-level singleton
``pf.config``
+accepts any Flink configuration key and buffers the value until
+``pf.get_or_create_table_environment()`` creates the underlying
TableEnvironment. Because
+the values are supplied at creation time, options that can only be chosen
then, such as
+``execution.runtime-mode``, take effect.
+
+Configuration must be set before the environment exists; ``pf.config.set()``
raises once an
+environment is active. An environment injected via
``pf.set_table_environment()`` is treated
+as fully configured and does not receive buffered values.
+
+Example::
+
+ >>> import pyflink.dataframe as pf
+ >>> _ = pf.config.set("parallelism.default", "4") \
+ ... .set("execution.runtime-mode", "batch")
+ >>> pf.config.get("parallelism.default")
+ '4'
+
+config
+------
+
+.. currentmodule:: pyflink.dataframe
+
+.. autosummary::
+ :toctree: api/
+
+ config
+ config.set
+ config.get
diff --git a/flink-python/docs/reference/pyflink.dataframe/index.rst
b/flink-python/docs/reference/pyflink.dataframe/index.rst
index 197cfccdaca..547cb0f2202 100644
--- a/flink-python/docs/reference/pyflink.dataframe/index.rst
+++ b/flink-python/docs/reference/pyflink.dataframe/index.rst
@@ -32,3 +32,4 @@ This page gives an overview of all public PyFlink DataFrame
APIs.
sql
datatype
environment
+ config
diff --git a/flink-python/pyflink/dataframe/__init__.py
b/flink-python/pyflink/dataframe/__init__.py
index 50496a3fa50..c8ed1cf08ae 100644
--- a/flink-python/pyflink/dataframe/__init__.py
+++ b/flink-python/pyflink/dataframe/__init__.py
@@ -52,6 +52,7 @@ from pyflink.dataframe.context import (
set_table_environment,
)
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.sql import sql
@@ -72,6 +73,7 @@ __all__ = [
"range",
"read_generic",
"sql",
+ "config",
"set_table_environment",
"get_table_environment",
"get_or_create_table_environment",
diff --git a/flink-python/pyflink/dataframe/context.py
b/flink-python/pyflink/dataframe/context.py
index 031c2995108..3abdcdaf403 100644
--- a/flink-python/pyflink/dataframe/context.py
+++ b/flink-python/pyflink/dataframe/context.py
@@ -18,8 +18,11 @@
from typing import Optional
-from pyflink.table import StreamTableEnvironment, TableEnvironment
+from pyflink.common import Configuration
+from pyflink.java_gateway import get_gateway
+from pyflink.table import EnvironmentSettings, StreamTableEnvironment,
TableEnvironment
from pyflink.util.api_stability_decorators import PublicEvolving
+from pyflink.util.java_utils import get_j_env_configuration
__all__ = [
"set_table_environment",
@@ -35,8 +38,15 @@ def set_table_environment(t_env: Optional[TableEnvironment])
-> None:
"""
Set the environment used by DataFrame operations.
+ The injected environment is treated as fully configured: values buffered in
+ :data:`config` are not applied to it. Injecting an environment while
:data:`config`
+ holds buffered values is rejected, because those values would otherwise be
silently
+ ignored. Passing ``None`` clears the environment and leaves the buffered
values intact.
+
:param t_env: Environment to use, or ``None`` to clear it.
:raises TypeError: If ``t_env`` is neither a :class:`TableEnvironment` nor
``None``.
+ :raises RuntimeError: If ``t_env`` is not ``None`` and :data:`config`
holds buffered
+ values.
Example::
@@ -50,6 +60,17 @@ def set_table_environment(t_env: Optional[TableEnvironment])
-> None:
global _global_table_environment
if t_env is not None and not isinstance(t_env, TableEnvironment):
raise TypeError("t_env must be a TableEnvironment or None")
+ if t_env is not None:
+ from pyflink.dataframe.dataframe_config import config
+
+ if config._buffered:
+ raise RuntimeError(
+ "pf.config holds buffered values that only apply to an
environment created by "
+ "get_or_create_table_environment(); they would be ignored by
the injected "
+ "environment. Configure that environment through
t_env.get_config() instead of "
+ "pf.config, or call get_or_create_table_environment() to have
the buffered "
+ "values applied."
+ )
_global_table_environment = t_env
@@ -77,7 +98,9 @@ def get_or_create_table_environment() -> TableEnvironment:
"""
Return the configured environment, creating one when necessary.
- The created environment is retained for subsequent DataFrame operations
and calls to
+ The environment is created from the values buffered in :data:`config`, so
options that
+ can only be chosen at creation time, such as ``execution.runtime-mode``,
take effect.
+ It is retained for subsequent DataFrame operations and calls to
:func:`get_table_environment`.
:return: The configured or newly created environment.
@@ -95,9 +118,40 @@ def get_or_create_table_environment() -> TableEnvironment:
global _global_table_environment
if _global_table_environment is None:
- from pyflink.datastream import StreamExecutionEnvironment
-
- stream_environment =
StreamExecutionEnvironment.get_execution_environment()
- _global_table_environment =
StreamTableEnvironment.create(stream_environment)
+ from pyflink.dataframe.dataframe_config import config
+ from pyflink.datastream import RuntimeExecutionMode,
StreamExecutionEnvironment
+
+ configuration = config._to_configuration()
+ stream_environment =
StreamExecutionEnvironment.get_execution_environment(configuration)
+
+ # The execution environment may merge deployment configuration (for
example, from the
+ # CLI or config.yaml) while it is being created. EnvironmentSettings
must use this
+ # effective configuration because some Table API options are consumed
during creation.
+ j_environment_configuration = get_j_env_configuration(
+ stream_environment._j_stream_execution_environment
+ )
+ environment_configuration = Configuration(
+ j_configuration=j_environment_configuration
+ )
+ settings_builder =
EnvironmentSettings.new_instance().with_configuration(
+ environment_configuration
+ )
+
+ # Match StreamTableEnvironment.create(executionEnvironment): Table API
supports only an
+ # explicit batch or streaming mode, so AUTOMATIC is treated as
streaming.
+ j_execution_options = (
+ get_gateway().jvm.org.apache.flink.configuration.ExecutionOptions
+ )
+ runtime_mode = RuntimeExecutionMode._from_j_execution_mode(
+ j_environment_configuration.get(j_execution_options.RUNTIME_MODE)
+ )
+ if runtime_mode == RuntimeExecutionMode.BATCH:
+ settings_builder.in_batch_mode()
+ else:
+ settings_builder.in_streaming_mode()
+ settings = settings_builder.build()
+ _global_table_environment = StreamTableEnvironment.create(
+ stream_environment, environment_settings=settings
+ )
return _global_table_environment
diff --git a/flink-python/pyflink/dataframe/dataframe_config.py
b/flink-python/pyflink/dataframe/dataframe_config.py
new file mode 100644
index 00000000000..f431c904b63
--- /dev/null
+++ b/flink-python/pyflink/dataframe/dataframe_config.py
@@ -0,0 +1,148 @@
+################################################################################
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+################################################################################
+
+from typing import Dict, Optional
+
+from pyflink.common import Configuration
+from pyflink.util.api_stability_decorators import PublicEvolving
+
+__all__ = [
+ "config",
+]
+
+
+class _DataFrameConfig:
+ """
+ A unified entry point for Flink configuration in the DataFrame API.
+
+ Accepts any Flink configuration key and buffers the value until
+ :func:`get_or_create_table_environment` creates the underlying
+ :class:`~pyflink.table.TableEnvironment`. Because the values are supplied
at creation
+ time, options that can only be chosen then, such as
``execution.runtime-mode`` or
+ ``table.builtin-catalog-name``, take effect.
+
+ Configuration must therefore be set before the environment exists. Once an
environment
+ is active, whether created or injected via :func:`set_table_environment`,
use its own
+ :meth:`~pyflink.table.TableEnvironment.get_config` instead. An environment
passed to
+ :func:`set_table_environment` is treated as fully configured and does not
receive
+ buffered values. Buffered values survive clearing the environment with
+ ``set_table_environment(None)`` and feed the next environment created.
+
+ Use the module-level singleton :data:`config` instead of instantiating
this class.
+
+ Example::
+
+ >>> import pyflink.dataframe as pf
+ >>> _ = pf.config.set("parallelism.default", "4")
+ >>> pf.config.get("parallelism.default")
+ '4'
+
+ .. versionadded:: 2.4.0
+ """
+
+ def __init__(self: "_DataFrameConfig"):
+ self._buffered: Dict[str, str] = {}
+
+ @PublicEvolving()
+ def set(self, key: str, value: str) -> "_DataFrameConfig":
+ """
+ Sets a string-based value for the given string-based key.
+
+ The value is buffered and supplied to the environment created by
+ :func:`get_or_create_table_environment`. It cannot be called while an
environment
+ is active, because options consumed at creation time could no longer
take effect;
+ configure the active environment through its
+ :meth:`~pyflink.table.TableEnvironment.get_config` instead.
+
+ :param key: The configuration key.
+ :param value: The configuration value. It will be parsed by the
framework on access.
+ :return: This object, to allow chaining of calls.
+ :raises TypeError: If ``key`` or ``value`` is not a string.
+ :raises RuntimeError: If an environment is already active.
+
+ Example::
+
+ >>> import pyflink.dataframe as pf
+ >>> _ = pf.config.set("parallelism.default", "4") \\
+ ... .set("execution.runtime-mode", "batch")
+
+ .. versionadded:: 2.4.0
+ """
+ if not isinstance(key, str):
+ raise TypeError("key must be a string")
+ if not isinstance(value, str):
+ raise TypeError("value must be a string")
+
+ from pyflink.dataframe.context import get_table_environment
+
+ if get_table_environment() is not None:
+ raise RuntimeError(
+ "DataFrame configuration must be set before the table
environment exists. "
+ "Configure the active environment through t_env.get_config(),
or clear it "
+ "with set_table_environment(None) before calling config.set()."
+ )
+ self._buffered[key] = value
+ return self
+
+ @PublicEvolving()
+ def get(self, key: str, default: Optional[str] = None) -> Optional[str]:
+ """
+ Returns the value associated with the given key as a string.
+
+ When an environment is active, the value is read from its
configuration, so values
+ set outside this object are visible as well; otherwise the value is
read from the
+ buffered values.
+
+ :param key: The configuration key.
+ :param default: The value returned when there is no value associated
with ``key``.
+ :return: The (default) value associated with ``key``.
+ :raises TypeError: If ``key`` is not a string, or ``default`` is
neither a string
+ nor ``None``.
+
+ Example::
+
+ >>> import pyflink.dataframe as pf
+ >>> _ = pf.config.set("parallelism.default", "4")
+ >>> pf.config.get("parallelism.default")
+ '4'
+ >>> pf.config.get("pipeline.name", "unnamed")
+ 'unnamed'
+
+ .. versionadded:: 2.4.0
+ """
+ if not isinstance(key, str):
+ raise TypeError("key must be a string")
+ if default is not None and not isinstance(default, str):
+ raise TypeError("default must be a string or None")
+
+ from pyflink.dataframe.context import get_table_environment
+
+ t_env = get_table_environment()
+ if t_env is not None:
+ return t_env.get_config().get(key, default)
+ return self._buffered.get(key, default)
+
+ def _to_configuration(self) -> Configuration:
+ configuration = Configuration()
+ for key, value in self._buffered.items():
+ configuration.set_string(key, value)
+ return configuration
+
+
+config = _DataFrameConfig()
+"""The singleton :class:`_DataFrameConfig` used by the DataFrame API."""
diff --git a/flink-python/pyflink/dataframe/tests/test_config.py
b/flink-python/pyflink/dataframe/tests/test_config.py
new file mode 100644
index 00000000000..d242bd9b2ee
--- /dev/null
+++ b/flink-python/pyflink/dataframe/tests/test_config.py
@@ -0,0 +1,187 @@
+################################################################################
+# 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 unittest
+from typing import get_type_hints, Optional
+
+import pyflink.dataframe as pf
+from pyflink.dataframe.dataframe_config import _DataFrameConfig
+from pyflink.testing.test_case_utils import PyFlinkUTTestCase
+
+
+class DataFrameConfigValidationTests(unittest.TestCase):
+ def setUp(self):
+ previous_environment = pf.get_table_environment()
+ self.addCleanup(pf.set_table_environment, previous_environment)
+ self.addCleanup(pf.config._buffered.clear)
+ pf.set_table_environment(None)
+ pf.config._buffered.clear()
+
+ def test_config_is_a_dataframe_config_singleton(self):
+ self.assertIsInstance(pf.config, _DataFrameConfig)
+ self.assertNotIn("DataFrameConfig", pf.__all__)
+ self.assertNotIn("_DataFrameConfig", pf.__all__)
+
+ def test_public_type_hints_are_resolvable(self):
+ self.assertEqual(
+ get_type_hints(_DataFrameConfig.set),
+ {
+ "key": str,
+ "value": str,
+ "return": _DataFrameConfig,
+ },
+ )
+ self.assertEqual(
+ get_type_hints(_DataFrameConfig.get),
+ {
+ "key": str,
+ "default": Optional[str],
+ "return": Optional[str],
+ },
+ )
+
+ def test_set_rejects_non_string_key_without_buffering(self):
+ with self.assertRaisesRegex(TypeError, "key must be a string"):
+ pf.config.set(1, "value")
+
+ self.assertEqual(pf.config._buffered, {})
+
+ def test_set_rejects_non_string_value_without_buffering(self):
+ with self.assertRaisesRegex(TypeError, "value must be a string"):
+ pf.config.set("pipeline.name", 1)
+
+ self.assertEqual(pf.config._buffered, {})
+
+ def test_get_rejects_non_string_key(self):
+ with self.assertRaisesRegex(TypeError, "key must be a string"):
+ pf.config.get(1)
+
+ def test_get_rejects_non_string_default(self):
+ with self.assertRaisesRegex(TypeError, "default must be a string or
None"):
+ pf.config.get("pipeline.name", 1)
+
+ def test_set_returns_the_config_for_chaining(self):
+ result = pf.config.set("pipeline.name",
"a").set("parallelism.default", "4")
+
+ self.assertIs(result, pf.config)
+
+ def test_buffered_value_is_returned_before_an_environment_exists(self):
+ pf.config.set("pipeline.name", "buffered")
+
+ self.assertEqual(pf.config.get("pipeline.name"), "buffered")
+
+ def test_default_is_returned_when_not_buffered(self):
+ self.assertIsNone(pf.config.get("pipeline.name"))
+ self.assertEqual(pf.config.get("pipeline.name", "fallback"),
"fallback")
+
+
+class DataFrameConfigTests(PyFlinkUTTestCase):
+ def setUp(self):
+ super().setUp()
+ previous_environment = pf.get_table_environment()
+ self.addCleanup(pf.set_table_environment, previous_environment)
+ self.addCleanup(pf.config._buffered.clear)
+ pf.set_table_environment(None)
+ pf.config._buffered.clear()
+
+ def
test_buffered_values_are_applied_to_the_lazily_created_environment(self):
+ pf.config.set("pipeline.name", "lazy-name")
+
+ created_environment = pf.get_or_create_table_environment()
+
+ self.assertEqual(
+ created_environment.get_config().get("pipeline.name", None),
"lazy-name"
+ )
+
+ def test_table_creation_time_option_takes_effect(self):
+ # The built-in catalog is chosen when the TableEnvironment is
instantiated, so the
+ # buffered value has to reach EnvironmentSettings rather than
TableConfig afterwards.
+ pf.config.set("table.builtin-catalog-name", "my_catalog")
+
+ created_environment = pf.get_or_create_table_environment()
+
+ self.assertEqual(created_environment.get_current_catalog(),
"my_catalog")
+
+ def test_set_is_rejected_while_an_environment_is_active(self):
+ pf.set_table_environment(self.t_env)
+
+ with self.assertRaisesRegex(RuntimeError, "before the table
environment exists"):
+ pf.config.set("pipeline.name", "too-late")
+
+ self.assertEqual(pf.config._buffered, {})
+ self.assertIsNone(self.t_env.get_config().get("pipeline.name", None))
+
+ def test_set_is_allowed_again_after_the_environment_is_cleared(self):
+ pf.set_table_environment(self.t_env)
+ pf.set_table_environment(None)
+
+ pf.config.set("pipeline.name", "after-clear")
+
+ self.assertEqual(pf.config.get("pipeline.name"), "after-clear")
+
+ def
test_injecting_an_environment_is_rejected_when_values_are_buffered(self):
+ pf.config.set("pipeline.name", "buffered-name")
+
+ with self.assertRaisesRegex(RuntimeError, "buffered values"):
+ pf.set_table_environment(self.t_env)
+
+ self.assertIsNone(pf.get_table_environment())
+ self.assertIsNone(self.t_env.get_config().get("pipeline.name", None))
+
+ def test_injected_environment_is_not_modified(self):
+ self.t_env.get_config().set("pipeline.name", "explicit")
+
+ pf.set_table_environment(self.t_env)
+
+ self.assertIs(pf.get_table_environment(), self.t_env)
+ self.assertEqual(self.t_env.get_config().get("pipeline.name", None),
"explicit")
+
+ def test_clearing_the_environment_keeps_buffered_values(self):
+ pf.config.set("pipeline.name", "kept")
+ pf.get_or_create_table_environment()
+
+ pf.set_table_environment(None)
+
+ self.assertEqual(pf.config.get("pipeline.name"), "kept")
+ created_environment = pf.get_or_create_table_environment()
+ self.assertEqual(created_environment.get_config().get("pipeline.name",
None), "kept")
+
+ def test_get_reads_from_the_active_environment(self):
+ pf.set_table_environment(self.t_env)
+ self.t_env.get_config().set("pipeline.name", "from-environment")
+
+ self.assertEqual(pf.config.get("pipeline.name"), "from-environment")
+
+ def
test_get_returns_default_when_missing_from_the_active_environment(self):
+ pf.set_table_environment(self.t_env)
+
+ self.assertEqual(pf.config.get("pipeline.name", "fallback"),
"fallback")
+
+ def test_runtime_mode_buffered_before_creation_takes_effect(self):
+ # The planner is chosen when the environment is instantiated, so a
buffered
+ # runtime mode must be visible at creation time rather than applied
afterwards.
+ pf.config.set("execution.runtime-mode", "batch")
+
+ created_environment = pf.get_or_create_table_environment()
+ table = created_environment.from_elements([(1, "a"), (2, "b")], ["id",
"name"])
+ with table.execute().collect() as rows:
+ self.assertEqual(len(list(rows)), 2)
+
+
+if __name__ == "__main__":
+ unittest.main()