This is an automated email from the ASF dual-hosted git repository.
JingsongLi pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/paimon.git
The following commit(s) were added to refs/heads/master by this push:
new 58527a2d0d [python] Unify the OSS User-Agent format (#10307)
58527a2d0d is described below
commit 58527a2d0d1e6f7695eafd7aad66f2a6bf86f36a
Author: Dapeng Sun(孙大鹏) <[email protected]>
AuthorDate: Fri Oct 2 09:42:14 2026 +0800
[python] Unify the OSS User-Agent format (#10307)
---
.../filesystem/jindo_file_system_handler.py | 10 +-
.../pypaimon/filesystem/oss_user_agent.py | 77 +++++++++++
.../pypaimon/filesystem/pyarrow_file_io.py | 3 +
.../pypaimon/tests/jindo_file_system_test.py | 55 +++++++-
.../pypaimon/tests/oss_user_agent_test.py | 150 +++++++++++++++++++++
5 files changed, 293 insertions(+), 2 deletions(-)
diff --git a/paimon-python/pypaimon/filesystem/jindo_file_system_handler.py
b/paimon-python/pypaimon/filesystem/jindo_file_system_handler.py
index eaad4a89be..bade450d9b 100644
--- a/paimon-python/pypaimon/filesystem/jindo_file_system_handler.py
+++ b/paimon-python/pypaimon/filesystem/jindo_file_system_handler.py
@@ -44,6 +44,7 @@ except ImportError:
from pypaimon.common.options import Options
from pypaimon.common.options.config import OssOptions
+from pypaimon.filesystem import oss_user_agent
_JINDO_CONFIG_PREFIXES = ("fs.", "logger.")
@@ -100,7 +101,14 @@ def build_jindo_config(catalog_options: Options):
config.set("fs.oss.endpoint", endpoint_clean)
if region:
config.set("fs.oss.region", region)
- config.set("fs.oss.user.agent.features", "pypaimon")
+ # This backend fills the module itself, so pypaimon/<version> leads the
features.
+ user_agent_module = oss_user_agent.module(catalog_options)
+ if user_agent_module:
+ config.set(oss_user_agent.USER_AGENT_MODULE, user_agent_module)
+ config.set(oss_user_agent.USER_AGENT_FEATURES,
oss_user_agent.features(catalog_options))
+ user_agent_extended = oss_user_agent.extended(catalog_options)
+ if user_agent_extended:
+ config.set(oss_user_agent.USER_AGENT_EXTENDED, user_agent_extended)
return config
diff --git a/paimon-python/pypaimon/filesystem/oss_user_agent.py
b/paimon-python/pypaimon/filesystem/oss_user_agent.py
new file mode 100644
index 0000000000..844499c3a5
--- /dev/null
+++ b/paimon-python/pypaimon/filesystem/oss_user_agent.py
@@ -0,0 +1,77 @@
+# 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.
+
+"""Parts of Paimon's unified OSS User-Agent ``module(transport;features)
extended``."""
+
+import functools
+from typing import List, Optional
+
+from pypaimon import build_info
+from pypaimon.common.options import Options
+
+USER_AGENT_MODULE = "fs.oss.user.agent.module"
+USER_AGENT_FEATURES = "fs.oss.user.agent.features"
+USER_AGENT_EXTENDED = "fs.oss.user.agent.extended"
+# Catalog-wide keys shared with the REST client; the fs.oss keys above take
precedence.
+COMMON_USER_AGENT_MODULE = "user-agent.module"
+COMMON_USER_AGENT_FEATURES = "user-agent.features"
+COMMON_USER_AGENT_EXTENDED = "user-agent.extended"
+DLF_ACCESS_TRACKING_EXTENDED_INFO = "dlf.access-tracking.extended-info"
+
+_NAME = "pypaimon"
+
+
[email protected]_cache(maxsize=None)
+def identity() -> str:
+ """``pypaimon/<version>`` with the version embedded at build time, or bare
``pypaimon``."""
+ version = build_info.version()
+ return "{}/{}".format(_NAME, version) if version else _NAME
+
+
+def module(options: Options) -> Optional[str]:
+ """The configured module, or None to keep the backend's own."""
+ return _effective(options, USER_AGENT_MODULE, COMMON_USER_AGENT_MODULE)
+
+
+def features(options: Options) -> str:
+ """The identity followed by the configured features, space separated."""
+ user_features = _split(_effective(options, USER_AGENT_FEATURES,
COMMON_USER_AGENT_FEATURES))
+ if any(f == _NAME or f.startswith(_NAME + "/") for f in user_features):
+ return " ".join(user_features)
+ return " ".join([identity()] + user_features)
+
+
+def extended(options: Options) -> Optional[str]:
+ """The configured extended info with the DLF access-tracking info
appended, or None."""
+ parts = [_effective(options, USER_AGENT_EXTENDED,
COMMON_USER_AGENT_EXTENDED),
+ _effective(options, DLF_ACCESS_TRACKING_EXTENDED_INFO)]
+ parts = [p for p in parts if p]
+ return " ".join(parts) if parts else None
+
+
+def _effective(options: Options, *keys: str) -> Optional[str]:
+ """The first non-blank value among ``keys``, stripped."""
+ data = options.to_map()
+ for key in keys:
+ value = data.get(key)
+ if value is not None and str(value).strip():
+ return str(value).strip()
+ return None
+
+
+def _split(value) -> List[str]:
+ return str(value).split() if value is not None else []
diff --git a/paimon-python/pypaimon/filesystem/pyarrow_file_io.py
b/paimon-python/pypaimon/filesystem/pyarrow_file_io.py
index 362994fb65..ad5d3c8a4e 100644
--- a/paimon-python/pypaimon/filesystem/pyarrow_file_io.py
+++ b/paimon-python/pypaimon/filesystem/pyarrow_file_io.py
@@ -35,6 +35,7 @@ from pypaimon.common.options import Options
from pypaimon.common.options.config import OssOptions, S3Options,
SecurityOptions
from pypaimon.common.options.options_utils import OptionsUtils
from pypaimon.common.uri_reader import UriReaderFactory
+from pypaimon.filesystem import oss_user_agent
from pypaimon.filesystem.jindo_file_system_handler import
JindoFileSystemHandler, JINDO_AVAILABLE
from pypaimon.schema.data_types import (AtomicType, DataField,
PyarrowFieldParser)
@@ -198,6 +199,8 @@ class PyArrowFileIO(FileIO):
# Uses setdefault so that an explicit user setting is never
overridden.
# Note: this is process-wide and affects all AWS SDK clients.
os.environ.setdefault("AWS_EC2_METADATA_DISABLED", "true")
+ # S3FileSystem takes no User-Agent; this adds app/pypaimon/<version>
to it, process-wide.
+ os.environ.setdefault("AWS_SDK_UA_APP_ID", oss_user_agent.identity())
client_kwargs = {
"access_key": self.properties.get(OssOptions.OSS_ACCESS_KEY_ID),
diff --git a/paimon-python/pypaimon/tests/jindo_file_system_test.py
b/paimon-python/pypaimon/tests/jindo_file_system_test.py
index 8c62113ca9..4cfae7303b 100644
--- a/paimon-python/pypaimon/tests/jindo_file_system_test.py
+++ b/paimon-python/pypaimon/tests/jindo_file_system_test.py
@@ -27,6 +27,7 @@ from pyarrow.fs import PyFileSystem
from pypaimon.common.options import Options
from pypaimon.common.options.config import OssOptions
from pypaimon.filesystem import jindo_file_system_handler as jindo_module
+from pypaimon.filesystem import oss_user_agent
from pypaimon.filesystem.jindo_file_system_handler import (
JindoFileSystemHandler,
JindoInputFile,
@@ -141,7 +142,10 @@ class JindoConfigTest(unittest.TestCase):
self.assertEqual(config.values["logger.dir"], "/tmp/jindo-log")
self.assertEqual(config.values["logger.verbose"], "3")
self.assertEqual(config.values["logger.console.log.enable"], "false")
- self.assertEqual(config.values["fs.oss.user.agent.features"],
"pypaimon")
+ self.assertEqual(
+ config.values["fs.oss.user.agent.features"],
oss_user_agent.identity())
+ self.assertNotIn("fs.oss.user.agent.module", config.values)
+ self.assertNotIn("fs.oss.user.agent.extended", config.values)
self.assertNotIn(OssOptions.OSS_IMPL.key(), config.values)
self.assertNotIn("fs.oss.unset.option", config.values)
self.assertNotIn("metastore", config.values)
@@ -172,6 +176,55 @@ class JindoConfigTest(unittest.TestCase):
self.assertNotIn("fs.oss.provider.endpoint", config.values)
self.assertNotIn("fs.oss.provider.format", config.values)
+ def test_user_agent_keeps_user_values_and_appends_access_tracking(self):
+ fake_jutil = types.SimpleNamespace(Config=_RecordingConfig)
+ options = Options({
+ "fs.oss.user.agent.module": "MyModule/1.0",
+ "fs.oss.user.agent.features": "Flink",
+ "fs.oss.user.agent.extended": "user/ext",
+ "dlf.access-tracking.extended-info": "acs/xxx k/v",
+ })
+
+ with mock.patch.object(jindo_module, "JINDO_AVAILABLE", True), \
+ mock.patch.object(jindo_module, "jutil", fake_jutil), \
+ mock.patch.object(oss_user_agent, "identity",
return_value="pypaimon/2.2.dev0"):
+ config = jindo_module.build_jindo_config(options)
+
+ self.assertEqual(config.values["fs.oss.user.agent.module"],
"MyModule/1.0")
+ self.assertEqual(
+ config.values["fs.oss.user.agent.features"], "pypaimon/2.2.dev0
Flink")
+ self.assertEqual(
+ config.values["fs.oss.user.agent.extended"], "user/ext acs/xxx
k/v")
+ self.assertNotIn("dlf.access-tracking.extended-info", config.values)
+
+ def test_user_agent_from_common_keys(self):
+ fake_jutil = types.SimpleNamespace(Config=_RecordingConfig)
+ options = Options({
+ "user-agent.module": "MyApp/1.0",
+ "user-agent.features": "Flink",
+ "user-agent.extended": "vvr",
+ "dlf.access-tracking.extended-info": "uid/123",
+ })
+
+ with mock.patch.object(jindo_module, "JINDO_AVAILABLE", True), \
+ mock.patch.object(jindo_module, "jutil", fake_jutil), \
+ mock.patch.object(oss_user_agent, "identity",
return_value="pypaimon/2.2.dev0"):
+ config = jindo_module.build_jindo_config(options)
+
+ self.assertEqual(config.values["fs.oss.user.agent.module"],
"MyApp/1.0")
+ self.assertEqual(config.values["fs.oss.user.agent.features"],
"pypaimon/2.2.dev0 Flink")
+ self.assertEqual(config.values["fs.oss.user.agent.extended"], "vvr
uid/123")
+
+ def test_user_agent_extended_from_access_tracking_only(self):
+ fake_jutil = types.SimpleNamespace(Config=_RecordingConfig)
+ options = Options({"dlf.access-tracking.extended-info": "acs/xxx"})
+
+ with mock.patch.object(jindo_module, "JINDO_AVAILABLE", True), \
+ mock.patch.object(jindo_module, "jutil", fake_jutil):
+ config = jindo_module.build_jindo_config(options)
+
+ self.assertEqual(config.values["fs.oss.user.agent.extended"],
"acs/xxx")
+
def test_forwards_native_options_to_jindo_oss_filesystem(self):
created_config = _RecordingConfig()
config_factory = mock.Mock(return_value=created_config)
diff --git a/paimon-python/pypaimon/tests/oss_user_agent_test.py
b/paimon-python/pypaimon/tests/oss_user_agent_test.py
new file mode 100644
index 0000000000..dee7fd822c
--- /dev/null
+++ b/paimon-python/pypaimon/tests/oss_user_agent_test.py
@@ -0,0 +1,150 @@
+# 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 os
+import unittest
+from unittest import mock
+
+from pypaimon import build_info
+from pypaimon.common.options import Options
+from pypaimon.common.options.config import OssOptions
+from pypaimon.filesystem import oss_user_agent
+from pypaimon.filesystem import pyarrow_file_io
+from pypaimon.filesystem.pyarrow_file_io import PyArrowFileIO
+
+IDENTITY = "pypaimon/2.2.dev0"
+
+
+class OssUserAgentIdentityTest(unittest.TestCase):
+
+ def setUp(self):
+ oss_user_agent.identity.cache_clear()
+ self.addCleanup(oss_user_agent.identity.cache_clear)
+
+ def test_identity_uses_build_version(self):
+ with mock.patch.object(build_info, "version", return_value="2.2.dev"):
+ self.assertEqual("pypaimon/2.2.dev", oss_user_agent.identity())
+
+ def test_identity_without_build_version(self):
+ with mock.patch.object(build_info, "version", return_value=None):
+ self.assertEqual("pypaimon", oss_user_agent.identity())
+
+ def test_build_version_from_full_version(self):
+ for full_version, expected in (("python-2.2.dev-abc123", "2.2.dev"),
+ ("python-2.2.0-UNKNOWN", "2.2.0"),
+ ("UNKNOWN", None)):
+ with mock.patch.object(build_info, "_FULL_VERSION", full_version):
+ self.assertEqual(expected, build_info.version())
+
+
+class OssUserAgentTest(unittest.TestCase):
+
+ def setUp(self):
+ patcher = mock.patch.object(oss_user_agent, "identity",
return_value=IDENTITY)
+ patcher.start()
+ self.addCleanup(patcher.stop)
+
+ def test_features_default_to_identity(self):
+ self.assertEqual(IDENTITY, oss_user_agent.features(Options({})))
+
+ def test_user_features_follow_identity(self):
+ options = Options({oss_user_agent.USER_AGENT_FEATURES: " Flink Spark
"})
+ self.assertEqual(IDENTITY + " Flink Spark",
oss_user_agent.features(options))
+
+ def test_identity_is_not_duplicated(self):
+ for user_features in ("pypaimon Flink", "Flink pypaimon/1.0"):
+ options = Options({oss_user_agent.USER_AGENT_FEATURES:
user_features})
+ self.assertEqual(user_features, oss_user_agent.features(options))
+
+ def test_access_tracking_is_appended_to_user_extended(self):
+ options = Options({
+ oss_user_agent.USER_AGENT_EXTENDED: "user/ext",
+ oss_user_agent.DLF_ACCESS_TRACKING_EXTENDED_INFO: "acs/xxx k/v",
+ })
+ self.assertEqual("user/ext acs/xxx k/v",
oss_user_agent.extended(options))
+
+ def test_extended_from_either_side_alone(self):
+ self.assertEqual("acs/xxx", oss_user_agent.extended(
+ Options({oss_user_agent.DLF_ACCESS_TRACKING_EXTENDED_INFO:
"acs/xxx"})))
+ self.assertEqual("user/ext", oss_user_agent.extended(
+ Options({oss_user_agent.USER_AGENT_EXTENDED: "user/ext"})))
+
+ def test_common_keys_are_used_without_oss_keys(self):
+ options = Options({
+ oss_user_agent.COMMON_USER_AGENT_MODULE: "MyApp/1.0",
+ oss_user_agent.COMMON_USER_AGENT_FEATURES: "Flink",
+ oss_user_agent.COMMON_USER_AGENT_EXTENDED: "vvr",
+ oss_user_agent.DLF_ACCESS_TRACKING_EXTENDED_INFO: "uid/123",
+ })
+ self.assertEqual("MyApp/1.0", oss_user_agent.module(options))
+ self.assertEqual(IDENTITY + " Flink", oss_user_agent.features(options))
+ self.assertEqual("vvr uid/123", oss_user_agent.extended(options))
+
+ def test_oss_keys_override_common_keys_per_part(self):
+ options = Options({
+ oss_user_agent.COMMON_USER_AGENT_MODULE: "MyApp/1.0",
+ oss_user_agent.COMMON_USER_AGENT_FEATURES: "Flink",
+ oss_user_agent.COMMON_USER_AGENT_EXTENDED: "vvr",
+ oss_user_agent.USER_AGENT_FEATURES: "Spark",
+ oss_user_agent.USER_AGENT_EXTENDED: " ",
+ })
+ self.assertEqual("MyApp/1.0", oss_user_agent.module(options))
+ self.assertEqual(IDENTITY + " Spark", oss_user_agent.features(options))
+ self.assertEqual("vvr", oss_user_agent.extended(options))
+ options = Options({oss_user_agent.COMMON_USER_AGENT_EXTENDED: "vvr",
+ oss_user_agent.USER_AGENT_EXTENDED: "oss/ext"})
+ self.assertEqual("oss/ext", oss_user_agent.extended(options))
+ self.assertIsNone(oss_user_agent.module(Options({})))
+
+ def test_blank_extended_is_ignored(self):
+ options = Options({
+ oss_user_agent.USER_AGENT_EXTENDED: " ",
+ oss_user_agent.DLF_ACCESS_TRACKING_EXTENDED_INFO: "",
+ })
+ self.assertIsNone(oss_user_agent.extended(options))
+ self.assertIsNone(oss_user_agent.extended(Options({})))
+
+
+class LegacyOssUserAgentTest(unittest.TestCase):
+
+ def _new_legacy_file_io(self):
+ options = Options({
+ OssOptions.OSS_ACCESS_KEY_ID.key(): "ak",
+ OssOptions.OSS_ACCESS_KEY_SECRET.key(): "sk",
+ OssOptions.OSS_ENDPOINT.key(): "oss-cn-test.example.com",
+ OssOptions.OSS_REGION.key(): "cn-test",
+ OssOptions.OSS_IMPL.key(): "legacy",
+ })
+ with mock.patch.object(pyarrow_file_io.pafs, "S3FileSystem") as
s3_filesystem, \
+ mock.patch.object(oss_user_agent, "identity",
return_value=IDENTITY):
+ PyArrowFileIO("oss://test-bucket/", options)
+ s3_filesystem.assert_called_once()
+
+ def test_sets_aws_app_id_when_absent(self):
+ with mock.patch.dict(os.environ):
+ os.environ.pop("AWS_SDK_UA_APP_ID", None)
+ self._new_legacy_file_io()
+ self.assertEqual(IDENTITY, os.environ["AWS_SDK_UA_APP_ID"])
+
+ def test_keeps_user_aws_app_id(self):
+ with mock.patch.dict(os.environ, {"AWS_SDK_UA_APP_ID": "my-app"}):
+ self._new_legacy_file_io()
+ self.assertEqual("my-app", os.environ["AWS_SDK_UA_APP_ID"])
+
+
+if __name__ == "__main__":
+ unittest.main()