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 d49e4f1c49 [python] Unify the REST User-Agent format (#10309)
d49e4f1c49 is described below

commit d49e4f1c49b2a36209eff3b56de72f1157866f09
Author: Dapeng Sun(孙大鹏) <[email protected]>
AuthorDate: Fri Oct 2 09:41:44 2026 +0800

    [python] Unify the REST User-Agent format (#10309)
---
 paimon-python/pypaimon/api/client.py               |  10 +-
 paimon-python/pypaimon/api/rest_api.py             |   4 +-
 paimon-python/pypaimon/api/token_loader.py         |   2 +
 paimon-python/pypaimon/build_info.py               |   8 ++
 paimon-python/pypaimon/common/options/config.py    |  12 +-
 paimon-python/pypaimon/common/user_agent.py        |  64 +++++++++
 paimon-python/pypaimon/filesystem/pvfs.py          |   3 +-
 .../pypaimon/tests/rest_user_agent_test.py         | 152 +++++++++++++++++++++
 8 files changed, 250 insertions(+), 5 deletions(-)

diff --git a/paimon-python/pypaimon/api/client.py 
b/paimon-python/pypaimon/api/client.py
index 9e73c41723..4e0214d179 100644
--- a/paimon-python/pypaimon/api/client.py
+++ b/paimon-python/pypaimon/api/client.py
@@ -37,6 +37,7 @@ from pypaimon.api.rest_exception import 
(AlreadyExistsException,
                                          ServiceFailureException,
                                          ServiceUnavailableException)
 from pypaimon.api.typedef import RESTAuthParameter
+from pypaimon.common import user_agent
 from pypaimon.common.json_util import JSON
 
 T = TypeVar('T', bound='RESTResponse')
@@ -286,7 +287,7 @@ class HttpClient(RESTClient):
     _READ_TIMEOUT_SECONDS = 180
     _MAX_RETRIES = 5
 
-    def __init__(self, uri: str):
+    def __init__(self, uri: str, user_agent_value: Optional[str] = None):
         self.logger = logging.getLogger(self.__class__.__name__)
         self.uri = _normalize_uri(uri)
         self.error_handler = DefaultErrorHandler.get_instance()
@@ -301,10 +302,15 @@ class HttpClient(RESTClient):
         # and pass it explicitly on every call (see ``_execute_request``).
         self._timeout = (self._CONNECT_TIMEOUT_SECONDS, 
self._READ_TIMEOUT_SECONDS)
 
+        # A per-request User-Agent, e.g. from ``header.User-Agent``, still 
takes precedence.
         self.session.headers.update({
-            'Accept': 'application/json'
+            'Accept': 'application/json',
+            'User-Agent': user_agent_value or user_agent.rest_user_agent(),
         })
 
+    def set_user_agent(self, value: str) -> None:
+        self.session.headers['User-Agent'] = value
+
     def set_error_handler(self, error_handler: ErrorHandler) -> None:
         self.error_handler = error_handler
 
diff --git a/paimon-python/pypaimon/api/rest_api.py 
b/paimon-python/pypaimon/api/rest_api.py
index aff1e8793b..f9ecb27faa 100755
--- a/paimon-python/pypaimon/api/rest_api.py
+++ b/paimon-python/pypaimon/api/rest_api.py
@@ -50,6 +50,7 @@ from pypaimon.api.client import HttpClient
 from pypaimon.api.resource_paths import ResourcePaths
 from pypaimon.api.rest_util import RESTUtil
 from pypaimon.api.typedef import T
+from pypaimon.common import user_agent
 from pypaimon.common.options import Options
 from pypaimon.common.options.config import CatalogOptions
 from pypaimon.common.identifier import Identifier
@@ -85,7 +86,7 @@ class RESTApi:
             raise ValueError("URI cannot be empty")
 
         self.logger = logging.getLogger(self.__class__.__name__)
-        self.client = HttpClient(uri)
+        self.client = HttpClient(uri, user_agent.rest_user_agent(options))
         auth_provider = AuthProviderFactory.create_auth_provider(options)
         base_headers = RESTUtil.extract_prefix_map(options, self.HEADER_PREFIX)
 
@@ -105,6 +106,7 @@ class RESTApi:
                 RESTAuthFunction(base_headers, auth_provider),
             )
             options = config_response.merge(options)
+            self.client.set_user_agent(user_agent.rest_user_agent(options))
             base_headers.update(
                 RESTUtil.extract_prefix_map(options, self.HEADER_PREFIX)
             )
diff --git a/paimon-python/pypaimon/api/token_loader.py 
b/paimon-python/pypaimon/api/token_loader.py
index 15e8035b0d..8f2affd220 100644
--- a/paimon-python/pypaimon/api/token_loader.py
+++ b/paimon-python/pypaimon/api/token_loader.py
@@ -27,6 +27,7 @@ from requests.adapters import HTTPAdapter
 from requests.exceptions import RequestException
 
 from pypaimon.api.client import ExponentialRetry
+from pypaimon.common import user_agent
 from pypaimon.common.options import Options
 from pypaimon.common.options.config import CatalogOptions
 from pypaimon.common.json_util import JSON, json_field, json_ignore_field
@@ -136,6 +137,7 @@ class HTTPClient:
         self.connect_timeout = connect_timeout
         self.read_timeout = read_timeout
         self.session = requests.Session()
+        self.session.headers['User-Agent'] = user_agent.rest_user_agent()
 
         # Add retry adapter
         retry_interceptor = ExponentialRetry(max_retries=3)
diff --git a/paimon-python/pypaimon/build_info.py 
b/paimon-python/pypaimon/build_info.py
index e034f356fd..22649caf77 100644
--- a/paimon-python/pypaimon/build_info.py
+++ b/paimon-python/pypaimon/build_info.py
@@ -84,3 +84,11 @@ _FULL_VERSION = _load_full_version()
 def full_version():
     """Return ``<pypaimon-version>-<commit-id>`` for snapshot provenance."""
     return _FULL_VERSION
+
+
+def version():
+    """Return the pypaimon version embedded at build time, or None when 
unknown."""
+    prefix = "python-"
+    if not _FULL_VERSION.startswith(prefix):
+        return None
+    return _FULL_VERSION[len(prefix):].rsplit("-", 1)[0] or None
diff --git a/paimon-python/pypaimon/common/options/config.py 
b/paimon-python/pypaimon/common/options/config.py
index 1792dbd1df..d6d627bd3e 100644
--- a/paimon-python/pypaimon/common/options/config.py
+++ b/paimon-python/pypaimon/common/options/config.py
@@ -131,7 +131,17 @@ class CatalogOptions:
         "If not set, will be automatically selected based on endpoint host.")
     PREFIX = 
ConfigOptions.key("prefix").string_type().no_default_value().with_description("Prefix")
     HTTP_USER_AGENT_HEADER = ConfigOptions.key(
-        
"header.HTTP_USER_AGENT").string_type().no_default_value().with_description("HTTP
 User Agent header")
+        "header.User-Agent").string_type().no_default_value().with_description(
+        "The User-Agent of REST catalog requests, replacing Paimon's unified 
one")
+    USER_AGENT_MODULE = ConfigOptions.key(
+        "user-agent.module").string_type().no_default_value().with_description(
+        "The module of Paimon's unified User-Agent, 'pypaimon/<version>' by 
default")
+    USER_AGENT_FEATURES = ConfigOptions.key(
+        
"user-agent.features").string_type().no_default_value().with_description(
+        "Space-separated features of Paimon's unified User-Agent")
+    USER_AGENT_EXTENDED = ConfigOptions.key(
+        
"user-agent.extended").string_type().no_default_value().with_description(
+        "Free text appended to Paimon's unified User-Agent")
     SYNC_ALL_PROPERTIES = 
ConfigOptions.key("sync-all-properties").boolean_type().default_value(True).with_description(
         "Sync all table properties to the catalog metastore")
     RESOLVING_FILE_IO_ENABLED = (
diff --git a/paimon-python/pypaimon/common/user_agent.py 
b/paimon-python/pypaimon/common/user_agent.py
new file mode 100644
index 0000000000..6bfac5677f
--- /dev/null
+++ b/paimon-python/pypaimon/common/user_agent.py
@@ -0,0 +1,64 @@
+# 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.
+
+"""Paimon's unified User-Agent ``module(transport;features) extended``."""
+
+import functools
+from typing import Iterable, Optional
+
+import requests
+
+from pypaimon import build_info
+from pypaimon.common.options import Options
+from pypaimon.common.options.config import CatalogOptions
+
+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 format_user_agent(module: str, transport: str, features: Iterable[str] = 
(),
+                      extended: Optional[str] = None) -> str:
+    """Render ``module(transport;feature...) extended``, skipping empty 
parts."""
+    user_agent = "{}({})".format(module, ";".join([transport] + [f for f in 
features if f]))
+    return "{} {}".format(user_agent, extended) if extended else user_agent
+
+
+def rest_user_agent(options: Optional[Options] = None) -> str:
+    """The User-Agent of REST requests, built from the catalog's 
``user-agent.*`` options."""
+    data = options.to_map() if options is not None else {}
+    module = _value(data, CatalogOptions.USER_AGENT_MODULE.key()) or identity()
+    features = (_value(data, CatalogOptions.USER_AGENT_FEATURES.key()) or 
"").split()
+    return format_user_agent(module, "python-requests/" + 
requests.__version__, features,
+                             _value(data, 
CatalogOptions.USER_AGENT_EXTENDED.key()))
+
+
+def with_feature(options: Options, feature: str) -> None:
+    """Put ``feature`` first in the ``user-agent.features`` option."""
+    key = CatalogOptions.USER_AGENT_FEATURES.key()
+    features = [f for f in (_value(options.to_map(), key) or "").split() if f 
!= feature]
+    options.set(CatalogOptions.USER_AGENT_FEATURES, " ".join([feature] + 
features))
+
+
+def _value(data, key: str) -> Optional[str]:
+    value = data.get(key)
+    return str(value).strip() if value is not None and str(value).strip() else 
None
diff --git a/paimon-python/pypaimon/filesystem/pvfs.py 
b/paimon-python/pypaimon/filesystem/pvfs.py
index a56128ca40..13030f08b8 100644
--- a/paimon-python/pypaimon/filesystem/pvfs.py
+++ b/paimon-python/pypaimon/filesystem/pvfs.py
@@ -34,6 +34,7 @@ from readerwriterlock import rwlock
 from pypaimon.api.api_response import GetTableResponse, GetTableTokenResponse
 from pypaimon.api.client import AlreadyExistsException, NoSuchResourceException
 from pypaimon.api.rest_api import RESTApi
+from pypaimon.common import user_agent
 from pypaimon.common.options import Options
 from pypaimon.common.options.config import CatalogOptions, OssOptions, 
PVFSOptions
 from pypaimon.common.identifier import Identifier
@@ -149,7 +150,7 @@ class PaimonVirtualFileSystem(fsspec.AbstractFileSystem):
     def __init__(self, options: Union[Options, Dict[str, str]] = None, 
**kwargs):
         if isinstance(options, dict):
             options = Options(options)
-        options.set(CatalogOptions.HTTP_USER_AGENT_HEADER, 'PythonPVFS')
+        user_agent.with_feature(options, 'PythonPVFS')
         self.options = options
         self.warehouse = options.get(CatalogOptions.WAREHOUSE)
         cache_expired_time = (
diff --git a/paimon-python/pypaimon/tests/rest_user_agent_test.py 
b/paimon-python/pypaimon/tests/rest_user_agent_test.py
new file mode 100644
index 0000000000..f2d254fb96
--- /dev/null
+++ b/paimon-python/pypaimon/tests/rest_user_agent_test.py
@@ -0,0 +1,152 @@
+# 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 threading
+import unittest
+from http.server import BaseHTTPRequestHandler, HTTPServer
+from unittest import mock
+
+import requests
+
+from pypaimon import PaimonVirtualFileSystem, build_info
+from pypaimon.api.rest_api import RESTApi
+from pypaimon.api.token_loader import HTTPClient
+from pypaimon.common import user_agent
+from pypaimon.common.options import Options
+
+IDENTITY = "pypaimon/2.2.dev0"
+REST_USER_AGENT = "{}(python-requests/{})".format(IDENTITY, 
requests.__version__)
+
+
+class UserAgentIdentityTest(unittest.TestCase):
+
+    def setUp(self):
+        user_agent.identity.cache_clear()
+        self.addCleanup(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", user_agent.identity())
+
+    def test_identity_without_build_version(self):
+        with mock.patch.object(build_info, "version", return_value=None):
+            self.assertEqual("pypaimon", 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 UserAgentFormatTest(unittest.TestCase):
+
+    def test_format(self):
+        self.assertEqual("m/1(t/2)", user_agent.format_user_agent("m/1", 
"t/2"))
+        self.assertEqual("m/1(t/2;a;b) ext k/v",
+                         user_agent.format_user_agent("m/1", "t/2", ["a", "", 
"b"], "ext k/v"))
+
+    def test_rest_user_agent(self):
+        with mock.patch.object(user_agent, "identity", return_value=IDENTITY):
+            self.assertEqual(REST_USER_AGENT, user_agent.rest_user_agent())
+            self.assertEqual(REST_USER_AGENT[:-1] + ";Flink) vvr", 
user_agent.rest_user_agent(
+                Options({"user-agent.features": " Flink ", 
"user-agent.extended": "vvr"})))
+            
self.assertEqual("MyApp/1.0(python-requests/{})".format(requests.__version__),
+                             
user_agent.rest_user_agent(Options({"user-agent.module": "MyApp/1.0"})))
+
+    def test_with_feature_goes_first_once(self):
+        options = Options({"user-agent.features": "Flink PythonPVFS"})
+        user_agent.with_feature(options, "PythonPVFS")
+        user_agent.with_feature(options, "PythonPVFS")
+        self.assertEqual("PythonPVFS Flink", 
options.to_map()["user-agent.features"])
+
+
+class RestUserAgentTest(unittest.TestCase):
+
+    def setUp(self):
+        self.requests = []
+        recorded = self.requests
+
+        class Handler(BaseHTTPRequestHandler):
+            def do_GET(self):
+                recorded.append(dict(self.headers))
+                body = ({"defaults": {}} if "/config" in self.path
+                        else {"databases": [], "nextPageToken": None})
+                data = json.dumps(body).encode("utf-8")
+                self.send_response(200)
+                self.send_header("Content-Type", "application/json")
+                self.send_header("Content-Length", str(len(data)))
+                self.end_headers()
+                self.wfile.write(data)
+
+            def log_message(self, *args):
+                pass
+
+        self.server = HTTPServer(("127.0.0.1", 0), Handler)
+        threading.Thread(target=self.server.serve_forever, daemon=True).start()
+        self.addCleanup(self.server.server_close)
+        self.addCleanup(self.server.shutdown)
+        self.uri = "http://127.0.0.1:{}".format(self.server.server_port)
+
+        patcher = mock.patch.object(user_agent, "identity", 
return_value=IDENTITY)
+        patcher.start()
+        self.addCleanup(patcher.stop)
+
+    def _options(self, **extra):
+        options = {"uri": self.uri, "warehouse": "wh", "token.provider": 
"bear", "token": "t"}
+        options.update(extra)
+        return options
+
+    def _user_agents(self):
+        return [headers.get("User-Agent") for headers in self.requests]
+
+    def test_default_user_agent(self):
+        RESTApi(Options(self._options())).list_databases()
+
+        self.assertEqual([REST_USER_AGENT, REST_USER_AGENT], 
self._user_agents())
+
+    def test_common_options_user_agent(self):
+        options = self._options(**{"user-agent.features": "Flink", 
"user-agent.extended": "vvr"})
+        RESTApi(Options(options)).list_databases()
+
+        expected = REST_USER_AGENT[:-1] + ";Flink) vvr"
+        self.assertEqual([expected, expected], self._user_agents())
+
+    def test_user_set_user_agent_wins(self):
+        options = self._options(**{"header.User-Agent": "starrocks/user", 
"user-agent.extended": "vvr"})
+        RESTApi(Options(options)).list_databases()
+
+        self.assertEqual(["starrocks/user", "starrocks/user"], 
self._user_agents())
+
+    def test_pvfs_user_agent(self):
+        PaimonVirtualFileSystem(self._options(**{"user-agent.features": 
"Flink"})).ls("pvfs://wh/")
+
+        pvfs_user_agent = REST_USER_AGENT[:-1] + ";PythonPVFS;Flink)"
+        self.assertEqual([pvfs_user_agent, pvfs_user_agent], 
self._user_agents())
+        for headers in self.requests:
+            self.assertNotIn("http_user_agent", {key.lower() for key in 
headers})
+
+    def test_token_loader_user_agent(self):
+        HTTPClient().get(self.uri + "/v1/config")
+
+        self.assertEqual([REST_USER_AGENT], self._user_agents())
+
+
+if __name__ == "__main__":
+    unittest.main()

Reply via email to