Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions paimon-python/pypaimon/api/rest_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
# under the License.

import logging
import platform
from typing import Callable, Dict, List, Optional, Union

import re
Expand Down Expand Up @@ -50,6 +51,7 @@
from pypaimon.api.resource_paths import ResourcePaths
from pypaimon.api.rest_util import RESTUtil
from pypaimon.api.typedef import T
from pypaimon import build_info
from pypaimon.common.options import Options
from pypaimon.common.options.config import CatalogOptions
from pypaimon.common.identifier import Identifier
Expand All @@ -60,6 +62,7 @@

class RESTApi:
HEADER_PREFIX = "header."
USER_AGENT_HEADER = "User-Agent"
READ_VIA_HEADER = "X-Paimon-Read-Via"
MAX_RESULTS = "maxResults"
PAGE_TOKEN = "pageToken"
Expand Down Expand Up @@ -88,6 +91,11 @@ def __init__(self, options: Union[Options, Dict[str, str]], config_required: boo
self.client = HttpClient(uri)
auth_provider = AuthProviderFactory.create_auth_provider(options)
base_headers = RESTUtil.extract_prefix_map(options, self.HEADER_PREFIX)
base_headers.setdefault(
self.USER_AGENT_HEADER,
"PyPaimon/{} Python/{}".format(
build_info.sdk_version(), platform.python_version()),
)

if config_required:
warehouse = options.get(CatalogOptions.WAREHOUSE)
Expand Down
12 changes: 12 additions & 0 deletions paimon-python/pypaimon/build_info.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,3 +84,15 @@ def _load_full_version():
def full_version():
"""Return ``<pypaimon-version>-<commit-id>`` for snapshot provenance."""
return _FULL_VERSION


def sdk_version():
"""Return the SDK version embedded in the build metadata."""
try:
full = full_version()
if not full.startswith("python-"):
return "unknown"
version, separator, _ = full[len("python-"):].rpartition("-")
return version if separator and version else "unknown"
except Exception:
return "unknown"
2 changes: 1 addition & 1 deletion paimon-python/pypaimon/common/options/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ 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("HTTP User Agent header")
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 = (
Expand Down
9 changes: 8 additions & 1 deletion paimon-python/pypaimon/filesystem/pvfs.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
import importlib
import logging
import posixpath
import platform
import time
from abc import ABC
from dataclasses import dataclass
Expand All @@ -31,6 +32,7 @@
from fsspec.implementations.local import LocalFileSystem
from readerwriterlock import rwlock

from pypaimon import build_info
from pypaimon.api.api_response import GetTableResponse, GetTableTokenResponse
from pypaimon.api.client import AlreadyExistsException, NoSuchResourceException
from pypaimon.api.rest_api import RESTApi
Expand Down Expand Up @@ -149,7 +151,12 @@ 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')
if not options.contains(CatalogOptions.HTTP_USER_AGENT_HEADER):
options.set(
CatalogOptions.HTTP_USER_AGENT_HEADER,
"PythonPVFS PyPaimon/{} Python/{}".format(
build_info.sdk_version(), platform.python_version()),
)
self.options = options
self.warehouse = options.get(CatalogOptions.WAREHOUSE)
cache_expired_time = (
Expand Down
172 changes: 172 additions & 0 deletions paimon-python/pypaimon/tests/user_agent_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
# 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 platform
import unittest
from unittest.mock import patch

from pypaimon.api.api_response import ConfigResponse
from pypaimon.api.rest_api import RESTApi
from pypaimon.api.typedef import RESTAuthParameter
from pypaimon.common.options.config import CatalogOptions, OssOptions
from pypaimon.filesystem.pvfs import PaimonVirtualFileSystem


class UserAgentTest(unittest.TestCase):

def test_rest_api_uses_sdk_and_python_versions_in_default_user_agent(self):
with patch(
"pypaimon.api.rest_api.build_info.full_version",
return_value="python-2.3.0-deadbeef"), patch(
"pypaimon.api.rest_api.platform.python_version",
return_value="3.11.9"):
rest_api = RESTApi(
{
CatalogOptions.URI.key(): "http://catalog",
CatalogOptions.TOKEN_PROVIDER.key(): "bear",
CatalogOptions.TOKEN.key(): "token",
},
config_required=False,
)

self.assertEqual(
"PyPaimon/2.3.0 Python/3.11.9",
rest_api.rest_auth_function.init_header["User-Agent"],
)

def test_rest_api_uses_unknown_version_when_version_lookup_fails(self):
with patch(
"pypaimon.api.rest_api.build_info.full_version",
side_effect=RuntimeError("unavailable")), patch(
"pypaimon.api.rest_api.platform.python_version",
return_value="3.11.9"):
rest_api = RESTApi(
{
CatalogOptions.URI.key(): "http://catalog",
CatalogOptions.TOKEN_PROVIDER.key(): "bear",
CatalogOptions.TOKEN.key(): "token",
},
config_required=False,
)

self.assertEqual(
"PyPaimon/unknown Python/3.11.9",
rest_api.rest_auth_function.init_header["User-Agent"],
)

def test_rest_api_uses_unknown_version_when_version_info_is_invalid(self):
with patch(
"pypaimon.api.rest_api.build_info.full_version",
return_value="UNKNOWN"), patch(
"pypaimon.api.rest_api.platform.python_version",
return_value="3.11.9"):
rest_api = RESTApi(
{
CatalogOptions.URI.key(): "http://catalog",
CatalogOptions.TOKEN_PROVIDER.key(): "bear",
CatalogOptions.TOKEN.key(): "token",
},
config_required=False,
)

self.assertEqual(
"PyPaimon/unknown Python/3.11.9",
rest_api.rest_auth_function.init_header["User-Agent"],
)

def test_rest_api_preserves_configured_user_agent(self):
rest_api = RESTApi(
{
CatalogOptions.URI.key(): "http://catalog",
CatalogOptions.TOKEN_PROVIDER.key(): "bear",
CatalogOptions.TOKEN.key(): "token",
"header.User-Agent": "custom-client/1.0",
},
config_required=False,
)

self.assertEqual(
"custom-client/1.0",
rest_api.rest_auth_function.init_header["User-Agent"],
)

def test_rest_config_request_uses_default_user_agent(self):
with patch("pypaimon.api.rest_api.HttpClient") as http_client_class, patch(
"pypaimon.api.rest_api.build_info.full_version",
return_value="python-2.3.0-deadbeef"), patch(
"pypaimon.api.rest_api.platform.python_version",
return_value="3.11.9"):
http_client = http_client_class.return_value
http_client.get_with_params.return_value = ConfigResponse(
defaults={}, overrides=None)
RESTApi(
{
CatalogOptions.URI.key(): "http://catalog",
CatalogOptions.WAREHOUSE.key(): "warehouse",
CatalogOptions.TOKEN_PROVIDER.key(): "bear",
CatalogOptions.TOKEN.key(): "token",
},
)

config_auth_function = http_client.get_with_params.call_args[0][3]
config_headers = config_auth_function.apply(
RESTAuthParameter("GET", "/v1/config", ""))
self.assertEqual(
"PyPaimon/2.3.0 Python/3.11.9",
config_headers["User-Agent"],
)

def test_pvfs_adds_default_user_agent(self):
pvfs = PaimonVirtualFileSystem(
{OssOptions.OSS_ACCESS_KEY_ID.key(): "ak"},
skip_instance_cache=True,
)

self.assertIn("header.User-Agent", pvfs.options.to_map())

def test_pvfs_includes_sdk_and_python_versions_in_default_user_agent(self):
with patch(
"pypaimon.filesystem.pvfs.build_info.full_version",
return_value="python-2.3.0-deadbeef"):
pvfs = PaimonVirtualFileSystem(
{OssOptions.OSS_ACCESS_KEY_ID.key(): "ak"},
skip_instance_cache=True,
)

self.assertEqual(
"PythonPVFS PyPaimon/2.3.0 Python/{}".format(
platform.python_version()),
pvfs.options.get(CatalogOptions.HTTP_USER_AGENT_HEADER),
)

def test_pvfs_preserves_configured_user_agent(self):
pvfs = PaimonVirtualFileSystem(
{
OssOptions.OSS_ACCESS_KEY_ID.key(): "ak",
"header.User-Agent": "custom-client/1.0",
},
skip_instance_cache=True,
)

self.assertEqual(
"custom-client/1.0",
pvfs.options.get(CatalogOptions.HTTP_USER_AGENT_HEADER),
)


if __name__ == "__main__":
unittest.main()
Loading