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 b727e235dc [java][python] Support OSS blob presigned URLs (#10040)
b727e235dc is described below
commit b727e235dc342c8f5143cc4277c8c1bcff2def99
Author: XiaoHongbo <[email protected]>
AuthorDate: Tue Sep 22 11:27:58 2026 +0800
[java][python] Support OSS blob presigned URLs (#10040)
---
docs/docs/multimodal-table/blob-references.mdx | 22 +-
docs/docs/pypaimon/blob-store.md | 14 +
.../org/apache/paimon/rest/RESTTokenFileIO.java | 90 +++-
.../apache/paimon/rest/RESTTokenFileIOTest.java | 129 +++++
.../org/apache/paimon/oss/OSSBlobPresigner.java | 25 +-
.../java/org/apache/paimon/oss/OSSFileIOTest.java | 12 +-
.../pypaimon/catalog/rest/rest_token_file_io.py | 71 ++-
paimon-python/pypaimon/common/file_io.py | 11 +
.../pypaimon/filesystem/caching_file_io.py | 3 +
.../pypaimon/filesystem/oss_blob_presigner.py | 250 ++++++++++
paimon-python/pypaimon/filesystem/oss_file_io.py | 58 ++-
.../pypaimon/filesystem/resolving_file_io.py | 4 +
paimon-python/pypaimon/multimodal/blob_store.py | 14 +
paimon-python/pypaimon/table/row/blob.py | 5 +
.../pypaimon/tests/multimodal_table_test.py | 21 +
.../pypaimon/tests/oss_blob_presigner_test.py | 530 +++++++++++++++++++++
.../pypaimon/tests/rest/rest_token_file_io_test.py | 121 +++++
17 files changed, 1318 insertions(+), 62 deletions(-)
diff --git a/docs/docs/multimodal-table/blob-references.mdx
b/docs/docs/multimodal-table/blob-references.mdx
index 0c81bf5a0e..898b2fb23e 100644
--- a/docs/docs/multimodal-table/blob-references.mdx
+++ b/docs/docs/multimodal-table/blob-references.mdx
@@ -186,9 +186,9 @@ scheme and authority. For a descriptor forwarded from
another table, pass the
original owning table as `source_table`; using the downstream descriptor table
or
an arbitrary external object path fails this check.
-Configure the catalog with the standard public HTTPS OSS endpoint. Internal
endpoints and
-endpoints without the `https` scheme are rejected because external consumers
must be able to fetch
-the returned URL:
+Use an HTTPS OSS endpoint. Java and PyPaimon preserve the configured endpoint,
including
+Internal endpoints, in the returned URL. Both the application and the URL
consumer must be
+able to reach that endpoint. For consumers that require public access,
configure a public endpoint:
```properties
fs.oss.endpoint=https://oss-cn-hangzhou.aliyuncs.com
@@ -246,6 +246,22 @@ String url =
table.fileIO(), table.location(), Duration.ofMinutes(5));
```
+PyPaimon exposes the same operation for descriptor-backed `Blob` values. Pass
the owning table's
+FileIO and storage path so PyPaimon can validate that the descriptor belongs
to the table:
+
+```python
+from datetime import timedelta
+
+url = blob.to_presigned_url(
+ table.file_io,
+ table.table_path,
+ timedelta(minutes=5),
+)
+```
+
+Install the OSS dependencies with `pip install 'pypaimon[oss]'`. For DLF REST
catalogs, PyPaimon
+refreshes the STS credentials before creating the materialized object and URL.
+
The materialized object's bytes are unchanged. Consumers must inspect the
content instead of
relying on a URL suffix or a format-specific content type. For direct model
`image_url` inputs, use
only formats verified with the target model; PDF is not covered by this entry
point.
diff --git a/docs/docs/pypaimon/blob-store.md b/docs/docs/pypaimon/blob-store.md
index faeb717d2f..e5d8cebab4 100644
--- a/docs/docs/pypaimon/blob-store.md
+++ b/docs/docs/pypaimon/blob-store.md
@@ -154,6 +154,20 @@ for obj_info in objects:
print(obj_info.key, obj_info.size, obj_info.columns)
```
+### Create a Presigned URL
+
+For an OSS-backed object, generate a temporary URL without downloading its
bytes:
+
+```python
+from datetime import timedelta
+
+video = table.blobs(column="video", key_column="id").get_object("video-001")
+video_url = video.to_presigned_url(timedelta(minutes=30))
+```
+
+The URL preserves the configured OSS endpoint and any byte range selected by
+`get_object`. The descriptor must belong to the table.
+
### Delete Objects
`delete_object` deletes one object key. `delete_objects` deletes a batch of
keys
diff --git
a/paimon-common/src/main/java/org/apache/paimon/rest/RESTTokenFileIO.java
b/paimon-common/src/main/java/org/apache/paimon/rest/RESTTokenFileIO.java
index d3618a3c14..6e469db3ee 100644
--- a/paimon-common/src/main/java/org/apache/paimon/rest/RESTTokenFileIO.java
+++ b/paimon-common/src/main/java/org/apache/paimon/rest/RESTTokenFileIO.java
@@ -191,7 +191,35 @@ public class RESTTokenFileIO implements FileIO {
if (!path.equals(tableRoot)) {
throw new IOException("Table root does not match RESTTokenFileIO
bound table root.");
}
- return fileIO().createBlobPresignedUrl(tableRoot, descriptor,
validity);
+ if (validity == null
+ || validity.isZero()
+ || validity.isNegative()
+ || validity.getNano() != 0) {
+ throw new IOException("Blob presigned URL validity must be
positive whole seconds.");
+ }
+ final long validityMillis;
+ try {
+ validityMillis = validity.toMillis();
+ } catch (ArithmeticException e) {
+ throw new IOException("Blob presigned URL validity is too large.",
e);
+ }
+
+ FileIOWithToken first = fileIOWithToken(validityMillis);
+ String url = first.fileIO.createBlobPresignedUrl(tableRoot,
descriptor, validity);
+ if (hasRemainingLifetime(first.token, validityMillis)) {
+ return url;
+ }
+
+ // The first call materialized the range. Refresh and sign the cached
object again so the
+ // returned URL has the requested lifetime.
+ FileIOWithToken refreshed = fileIOWithToken(validityMillis);
+ url = refreshed.fileIO.createBlobPresignedUrl(tableRoot, descriptor,
validity);
+ if (!hasRemainingLifetime(refreshed.token, validityMillis)) {
+ throw new IOException(
+ "Requested presigned URL validity exceeds the remaining "
+ + "REST credential lifetime after refresh.");
+ }
+ return url;
}
@Override
@@ -204,21 +232,31 @@ public class RESTTokenFileIO implements FileIO {
}
public FileIO fileIO() throws IOException {
- tryToRefreshToken();
+ return fileIOWithToken(0).fileIO;
+ }
+
+ private FileIOWithToken fileIOWithToken(long minimumValidityMillis) throws
IOException {
+ tryToRefreshToken(minimumValidityMillis);
+ RESTToken currentToken = token;
+ if (!hasRemainingLifetime(currentToken, minimumValidityMillis)) {
+ throw new IOException(
+ "Requested presigned URL validity exceeds the remaining "
+ + "REST credential lifetime after refresh.");
+ }
- FileIO fileIO = FILE_IO_CACHE.getIfPresent(token);
+ FileIO fileIO = FILE_IO_CACHE.getIfPresent(currentToken);
if (fileIO != null) {
- return fileIO;
+ return new FileIOWithToken(fileIO, currentToken);
}
synchronized (FILE_IO_CACHE) {
- fileIO = FILE_IO_CACHE.getIfPresent(token);
+ fileIO = FILE_IO_CACHE.getIfPresent(currentToken);
if (fileIO != null) {
- return fileIO;
+ return new FileIOWithToken(fileIO, currentToken);
}
Options options = catalogContext.options();
- options = new Options(RESTUtil.merge(options.toMap(),
token.token()));
+ options = new Options(RESTUtil.merge(options.toMap(),
currentToken.token()));
options.set(FILE_IO_ALLOW_CACHE, false);
CatalogContext context =
CatalogContext.create(
@@ -227,25 +265,49 @@ public class RESTTokenFileIO implements FileIO {
catalogContext.preferIO(),
catalogContext.fallbackIO());
fileIO = FileIO.get(path, context);
- FILE_IO_CACHE.put(token, fileIO);
- return fileIO;
+ FILE_IO_CACHE.put(currentToken, fileIO);
+ return new FileIOWithToken(fileIO, currentToken);
}
}
+ private boolean hasRemainingLifetime(RESTToken signingToken, long
minimumValidityMillis) {
+ return signingToken == null
+ || signingToken.expireAtMillis() - currentTimeMillis() >=
minimumValidityMillis;
+ }
+
+ long currentTimeMillis() {
+ return System.currentTimeMillis();
+ }
+
private void tryToRefreshToken() {
- if (shouldRefresh()) {
+ tryToRefreshToken(0);
+ }
+
+ private void tryToRefreshToken(long minimumValidityMillis) {
+ if (shouldRefresh(minimumValidityMillis)) {
synchronized (this) {
- if (shouldRefresh()) {
+ if (shouldRefresh(minimumValidityMillis)) {
refreshToken();
}
}
}
}
- private boolean shouldRefresh() {
+ private boolean shouldRefresh(long minimumValidityMillis) {
return token == null
- || token.expireAtMillis() - System.currentTimeMillis()
- < TOKEN_EXPIRATION_SAFE_TIME_MILLIS;
+ || token.expireAtMillis() - currentTimeMillis()
+ < Math.max(TOKEN_EXPIRATION_SAFE_TIME_MILLIS,
minimumValidityMillis);
+ }
+
+ private static class FileIOWithToken {
+
+ private final FileIO fileIO;
+ private final RESTToken token;
+
+ private FileIOWithToken(FileIO fileIO, RESTToken token) {
+ this.fileIO = fileIO;
+ this.token = token;
+ }
}
private void refreshToken() {
diff --git
a/paimon-common/src/test/java/org/apache/paimon/rest/RESTTokenFileIOTest.java
b/paimon-common/src/test/java/org/apache/paimon/rest/RESTTokenFileIOTest.java
index 8aefb6b4a6..e80f097fcf 100644
---
a/paimon-common/src/test/java/org/apache/paimon/rest/RESTTokenFileIOTest.java
+++
b/paimon-common/src/test/java/org/apache/paimon/rest/RESTTokenFileIOTest.java
@@ -35,12 +35,14 @@ import java.io.IOException;
import java.time.Duration;
import java.util.Collections;
import java.util.UUID;
+import java.util.concurrent.atomic.AtomicLong;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@@ -97,6 +99,133 @@ class RESTTokenFileIOTest {
.hasMessageContaining("bound table root");
}
+ @Test
+ void testPresigningRefreshesForRequestedLifetime() throws IOException {
+ checkPresignedLifetime(
+ Duration.ofHours(3), Duration.ofHours(2), Duration.ofHours(4),
true, false);
+ }
+
+ @Test
+ void testPresigningReusesSufficientLifetime() throws IOException {
+ checkPresignedLifetime(
+ Duration.ofMinutes(30), Duration.ofHours(2),
Duration.ofHours(4), false, false);
+ }
+
+ @Test
+ void testPresigningAcceptsWhenOneSecondExceedsValidity() throws
IOException {
+ checkPresignedLifetime(
+ Duration.ofMinutes(90),
+ Duration.ofMinutes(90).plusSeconds(1),
+ Duration.ofHours(3),
+ false,
+ false);
+ }
+
+ @Test
+ void testPresigningRejectsInsufficientRefreshedLifetime() throws
IOException {
+ checkPresignedLifetime(
+ Duration.ofHours(3), Duration.ofHours(2), Duration.ofHours(2),
true, true);
+ }
+
+ private void checkPresignedLifetime(
+ Duration validity,
+ Duration initialLifetime,
+ Duration refreshedLifetime,
+ boolean refresh,
+ boolean rejected)
+ throws IOException {
+ Path root = new Path("oss://bucket/table");
+ BlobDescriptor descriptor = new
BlobDescriptor("oss://bucket/table/data.blob", 0, 1);
+ FileIO delegate = mock(FileIO.class);
+ when(delegate.exists(any())).thenReturn(true);
+ when(delegate.createBlobPresignedUrl(root, descriptor, validity))
+ .thenReturn("https://signed");
+ FileIOLoader loader = mock(FileIOLoader.class);
+ when(loader.load(any())).thenReturn(delegate);
+ when(loader.getScheme()).thenReturn("oss");
+ RESTApi api = mock(RESTApi.class);
+ Identifier identifier = Identifier.create("db", "table");
+ long now = System.currentTimeMillis();
+ when(api.loadTableToken(identifier))
+ .thenReturn(
+ new GetTableTokenResponse(
+ Collections.singletonMap(
+ "test.token",
UUID.randomUUID().toString()),
+ now + initialLifetime.toMillis()),
+ new GetTableTokenResponse(
+ Collections.singletonMap(
+ "test.token",
UUID.randomUUID().toString()),
+ now + refreshedLifetime.toMillis()));
+ RESTTokenFileIO fileIO =
+ new RESTTokenFileIO(
+ CatalogContext.create(new Options(), loader, null),
api, identifier, root) {
+ @Override
+ long currentTimeMillis() {
+ return now;
+ }
+ };
+ fileIO.validToken();
+ if (rejected) {
+ assertThatThrownBy(() -> fileIO.createBlobPresignedUrl(root,
descriptor, validity))
+ .isInstanceOf(IOException.class)
+ .hasMessageContaining("credential lifetime after refresh");
+ verify(delegate, never()).createBlobPresignedUrl(any(), any(),
any());
+ } else {
+ assertThat(fileIO.createBlobPresignedUrl(root, descriptor,
validity))
+ .isEqualTo("https://signed");
+ verify(delegate).createBlobPresignedUrl(root, descriptor,
validity);
+ }
+ verify(api, times(refresh ? 2 : 1)).loadTableToken(identifier);
+ }
+
+ @Test
+ void testPresigningRefreshesAndResignsAfterMaterialization() throws
IOException {
+ Path root = new Path("oss://bucket/table");
+ BlobDescriptor descriptor = new
BlobDescriptor("oss://bucket/table/data.blob", 0, 1);
+ Duration validity = Duration.ofMinutes(30);
+ AtomicLong now = new AtomicLong(1700000000000L);
+ FileIO delegate = mock(FileIO.class);
+ when(delegate.exists(any())).thenReturn(true);
+ when(delegate.createBlobPresignedUrl(root, descriptor, validity))
+ .thenAnswer(
+ ignored -> {
+ if (now.get() == 1700000000000L) {
+ now.addAndGet(Duration.ofHours(2).toMillis());
+ return "https://first";
+ }
+ return "https://refreshed";
+ });
+ FileIOLoader loader = mock(FileIOLoader.class);
+ when(loader.load(any())).thenReturn(delegate);
+ when(loader.getScheme()).thenReturn("oss");
+ RESTApi api = mock(RESTApi.class);
+ Identifier identifier = Identifier.create("db", "table");
+ when(api.loadTableToken(identifier))
+ .thenReturn(
+ new GetTableTokenResponse(
+ Collections.singletonMap(
+ "test.token",
UUID.randomUUID().toString()),
+ now.get() + Duration.ofHours(2).toMillis()),
+ new GetTableTokenResponse(
+ Collections.singletonMap(
+ "test.token",
UUID.randomUUID().toString()),
+ now.get() + Duration.ofHours(4).toMillis()));
+ RESTTokenFileIO fileIO =
+ new RESTTokenFileIO(
+ CatalogContext.create(new Options(), loader, null),
api, identifier, root) {
+ @Override
+ long currentTimeMillis() {
+ return now.get();
+ }
+ };
+
+ fileIO.validToken();
+ assertThat(fileIO.createBlobPresignedUrl(root, descriptor, validity))
+ .isEqualTo("https://refreshed");
+ verify(delegate, times(2)).createBlobPresignedUrl(root, descriptor,
validity);
+ verify(api, times(2)).loadTableToken(identifier);
+ }
+
@Test
void testFileIOCreationFailureSurfacesAsCheckedIOException() throws
IOException {
Path tableRoot = new Path("resttoken-broken://bucket/table");
diff --git
a/paimon-filesystems/paimon-oss-impl/src/main/java/org/apache/paimon/oss/OSSBlobPresigner.java
b/paimon-filesystems/paimon-oss-impl/src/main/java/org/apache/paimon/oss/OSSBlobPresigner.java
index 09aefa263b..55f9089e32 100644
---
a/paimon-filesystems/paimon-oss-impl/src/main/java/org/apache/paimon/oss/OSSBlobPresigner.java
+++
b/paimon-filesystems/paimon-oss-impl/src/main/java/org/apache/paimon/oss/OSSBlobPresigner.java
@@ -45,14 +45,12 @@ import java.time.Instant;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
-import java.util.Locale;
/** Materializes blob ranges and creates OSS presigned URLs. */
public final class OSSBlobPresigner {
private static final String BLOB_FINGERPRINT_METADATA =
"paimon-blob-descriptor-sha256";
private static final String BLOB_CONTENT_TYPE = "application/octet-stream";
- private static final String OSS_INTERNAL_ENDPOINT_SUFFIX =
"-internal.aliyuncs.com";
private static final long BLOB_COPY_MIN_PART_SIZE = 100L * 1024 * 1024;
private static final long MAX_MULTIPART_UPLOAD_PARTS = 10_000;
private static final char[] HEX_CHARS = "0123456789abcdef".toCharArray();
@@ -96,7 +94,6 @@ public final class OSSBlobPresigner {
targetKey,
Date.from(Instant.now().plus(validity)),
HttpMethod.GET);
- url = usePublicEndpoint(url);
validatePresignedUrl(client, url, bucket, targetKey);
return url.toString();
} catch (IOException e) {
@@ -222,7 +219,7 @@ public final class OSSBlobPresigner {
private static void validatePresignedUrl(
OSSClient client, URL url, String bucket, String targetKey) throws
Exception {
URI endpoint = client.getEndpoint();
- String expectedHost = publicEndpointHost(bucket + "." +
endpoint.getHost());
+ String expectedHost = bucket + "." + endpoint.getHost();
if (!"https".equalsIgnoreCase(endpoint.getScheme())
|| !"https".equalsIgnoreCase(url.getProtocol())
|| !expectedHost.equalsIgnoreCase(url.getHost())
@@ -230,24 +227,4 @@ public final class OSSBlobPresigner {
throw new IOException("OSS client generated a presigned URL for an
invalid target.");
}
}
-
- private static URL usePublicEndpoint(URL url) throws IOException {
- String publicHost = publicEndpointHost(url.getHost());
- if (publicHost.equals(url.getHost())) {
- return url;
- }
- String file = url.getFile();
- if (url.getRef() != null) {
- file += "#" + url.getRef();
- }
- return new URL(url.getProtocol(), publicHost, url.getPort(), file);
- }
-
- private static String publicEndpointHost(String host) {
- if
(!host.toLowerCase(Locale.ROOT).endsWith(OSS_INTERNAL_ENDPOINT_SUFFIX)) {
- return host;
- }
- return host.substring(0, host.length() -
OSS_INTERNAL_ENDPOINT_SUFFIX.length())
- + ".aliyuncs.com";
- }
}
diff --git
a/paimon-filesystems/paimon-oss-impl/src/test/java/org/apache/paimon/oss/OSSFileIOTest.java
b/paimon-filesystems/paimon-oss-impl/src/test/java/org/apache/paimon/oss/OSSFileIOTest.java
index 0e68472e8a..e801d25f81 100644
---
a/paimon-filesystems/paimon-oss-impl/src/test/java/org/apache/paimon/oss/OSSFileIOTest.java
+++
b/paimon-filesystems/paimon-oss-impl/src/test/java/org/apache/paimon/oss/OSSFileIOTest.java
@@ -152,7 +152,7 @@ public class OSSFileIOTest {
}
@Test
- public void
testCreateBlobPresignedUrlUsesPublicEndpointWithoutChangingPath() throws
Exception {
+ public void testCreateBlobPresignedUrlPreservesInternalEndpoint() throws
Exception {
OSSClient client = mock(OSSClient.class);
BlobDescriptor descriptor =
new
BlobDescriptor("oss://bucket/table/-internal.aliyuncs.com/source.blob", 0, 1);
@@ -176,7 +176,7 @@ public class OSSFileIOTest {
descriptor,
Duration.ofMinutes(5)));
-
assertThat(url.getHost()).isEqualTo("bucket.oss-cn-hangzhou.aliyuncs.com");
+
assertThat(url.getHost()).isEqualTo("bucket.oss-cn-hangzhou-internal.aliyuncs.com");
assertThat(url.getPath())
.isEqualTo(
"/table/-internal.aliyuncs.com/_bloburl_"
@@ -316,7 +316,13 @@ public class OSSFileIOTest {
}
@Test
- public void testCreateBlobPresignedUrlRejectsInvalidTarget() {
+ public void testCreateBlobPresignedUrlRejectsInvalidTarget() throws
Exception {
+ assertInvalidPresignedUrl(
+ "https://oss-cn-hangzhou-internal.aliyuncs.com",
+ "https://bucket.oss-cn-hangzhou.aliyuncs.com/table/_bloburl_"
+ + sha256Hex(
+ new
BlobDescriptor("oss://bucket/table/source.blob", 0, 1)
+ .serialize()));
assertInvalidPresignedUrl(
"https://oss-cn-hangzhou.aliyuncs.com",
"http://bucket.oss-cn-hangzhou.aliyuncs.com/table/_bloburl_hash");
diff --git a/paimon-python/pypaimon/catalog/rest/rest_token_file_io.py
b/paimon-python/pypaimon/catalog/rest/rest_token_file_io.py
index 6a87d004eb..2931712851 100644
--- a/paimon-python/pypaimon/catalog/rest/rest_token_file_io.py
+++ b/paimon-python/pypaimon/catalog/rest/rest_token_file_io.py
@@ -18,6 +18,7 @@
import logging
import threading
import time
+from datetime import timedelta
from typing import Optional, Union
from cachetools import TTLCache
@@ -86,21 +87,33 @@ class RESTTokenFileIO(FileIO):
self._init_file_io_cache()
def file_io(self) -> FileIO:
+ return self._file_io_with_token()[0]
+
+ def _file_io_with_token(self, minimum_validity_millis: int = 0):
with self._file_io_cache_lock:
- self.try_to_refresh_token()
+ if minimum_validity_millis:
+ self.try_to_refresh_token(minimum_validity_millis)
+ if not self._has_remaining_lifetime(
+ self.token, minimum_validity_millis):
+ raise ValueError(
+ "Requested presigned URL validity exceeds the
remaining "
+ "REST credential lifetime after refresh.")
+ else:
+ self.try_to_refresh_token()
if self.token is None:
- return FileIO.get(self.path, self.catalog_options or
Options({}))
+ return (FileIO.get(
+ self.path, self.catalog_options or Options({})), None)
cache_key = self.token
cache = self._file_io_cache
file_io = cache.get(cache_key)
if file_io is not None:
- return file_io
+ return file_io, cache_key
merged_properties = RESTUtil.merge(
self.catalog_options.to_map() if self.catalog_options else {},
- self.token.token
+ cache_key.token
)
if self.catalog_options:
dlf_oss_endpoint =
self.catalog_options.get(CatalogOptions.DLF_OSS_ENDPOINT)
@@ -110,7 +123,7 @@ class RESTTokenFileIO(FileIO):
file_io = FileIO.get(self.path, merged_options)
cache[cache_key] = file_io
- return file_io
+ return file_io, cache_key
def _merge_token_with_catalog_options(self, token: dict) -> dict:
"""Merge token with catalog options, DLF OSS endpoint should override
the standard OSS endpoint."""
@@ -154,6 +167,31 @@ class RESTTokenFileIO(FileIO):
def try_to_write_atomic(self, path: str, content: str) -> bool:
return self.file_io().try_to_write_atomic(path, content)
+ def create_blob_presigned_url(self, table_root, descriptor, validity) ->
str:
+ if table_root != self.path:
+ raise ValueError("Table root does not match RESTTokenFileIO bound
table root.")
+ if not isinstance(validity, timedelta):
+ raise TypeError("Blob presigned URL validity must be
datetime.timedelta.")
+ if validity <= timedelta(0) or validity.microseconds != 0:
+ raise ValueError("Blob presigned URL validity must be positive
whole seconds.")
+ validity_millis = int(validity.total_seconds() * 1000)
+ file_io, signing_token = self._file_io_with_token(validity_millis)
+ url = file_io.create_blob_presigned_url(
+ table_root, descriptor, validity)
+ if self._has_remaining_lifetime(signing_token, validity_millis):
+ return url
+
+ # The first call materialized the range. Refresh and sign the cached
+ # object again so the returned URL has the requested lifetime.
+ file_io, signing_token = self._file_io_with_token(validity_millis)
+ url = file_io.create_blob_presigned_url(
+ table_root, descriptor, validity)
+ if not self._has_remaining_lifetime(signing_token, validity_millis):
+ raise ValueError(
+ "Requested presigned URL validity exceeds the remaining "
+ "REST credential lifetime after refresh.")
+ return url
+
def write_parquet(self, path: str, data, compression: str = 'zstd',
zstd_level: int = 1, **kwargs):
return self.file_io().write_parquet(path, data, compression,
zstd_level, **kwargs)
@@ -207,14 +245,14 @@ class RESTTokenFileIO(FileIO):
return auth_provider.token or 'anonymous'
return 'anonymous'
- def try_to_refresh_token(self):
+ def try_to_refresh_token(self, minimum_validity_millis: int = 0):
identifier_str = self._build_cache_key()
- if self.token is not None and not self._is_token_expired(self.token):
+ if self.token is not None and not self._is_token_expired(self.token,
minimum_validity_millis):
return
cached_token = self._get_cached_token(identifier_str)
- if cached_token and not self._is_token_expired(cached_token):
+ if cached_token and not self._is_token_expired(cached_token,
minimum_validity_millis):
self.token = cached_token
return
@@ -222,12 +260,12 @@ class RESTTokenFileIO(FileIO):
with global_lock:
cached_token = self._get_cached_token(identifier_str)
- if cached_token and not self._is_token_expired(cached_token):
+ if cached_token and not self._is_token_expired(cached_token,
minimum_validity_millis):
self.token = cached_token
return
token_to_check = cached_token if cached_token else self.token
- if token_to_check is None or
self._is_token_expired(token_to_check):
+ if token_to_check is None or
self._is_token_expired(token_to_check, minimum_validity_millis):
self.refresh_token()
self._set_cached_token(identifier_str, self.token)
@@ -239,11 +277,20 @@ class RESTTokenFileIO(FileIO):
with self._TOKEN_LOCKS_LOCK:
self._TOKEN_CACHE[identifier_str] = token
- def _is_token_expired(self, token: Optional[RESTToken]) -> bool:
+ def _is_token_expired(
+ self, token: Optional[RESTToken], minimum_validity_millis: int =
0) -> bool:
if token is None:
return True
current_time = int(time.time() * 1000)
- return (token.expire_at_millis - current_time) <
RESTApi.TOKEN_EXPIRATION_SAFE_TIME_MILLIS
+ return (token.expire_at_millis - current_time) < max(
+ RESTApi.TOKEN_EXPIRATION_SAFE_TIME_MILLIS, minimum_validity_millis)
+
+ @staticmethod
+ def _has_remaining_lifetime(
+ token: Optional[RESTToken], minimum_validity_millis: int) -> bool:
+ return (token is None or
+ token.expire_at_millis - int(time.time() * 1000)
+ >= minimum_validity_millis)
def _get_global_token_lock(self, identifier_str: str) -> threading.Lock:
with self._TOKEN_LOCKS_LOCK:
diff --git a/paimon-python/pypaimon/common/file_io.py
b/paimon-python/pypaimon/common/file_io.py
index 23b2e51248..56c239de11 100644
--- a/paimon-python/pypaimon/common/file_io.py
+++ b/paimon-python/pypaimon/common/file_io.py
@@ -525,6 +525,17 @@ class FileIO(ABC):
target_file = f"{target_directory.rstrip('/')}/{file_name}" if
target_directory else file_name
self.copy_file(source_file, target_file, overwrite)
+ def create_blob_presigned_url(self, table_root, descriptor, validity) ->
str:
+ """Create a temporary URL for a descriptor-backed BLOB.
+
+ File systems which support presigning override this method.
``table_root``
+ identifies the table which owns ``descriptor`` and ``validity`` is a
+ :class:`datetime.timedelta` containing positive whole seconds.
+ """
+ raise NotImplementedError(
+ "{} does not support creating blob presigned URLs.".format(
+ type(self).__name__))
+
def read_overwritten_file_utf8(self, path: str) -> Optional[str]:
retry_number = 0
exception = None
diff --git a/paimon-python/pypaimon/filesystem/caching_file_io.py
b/paimon-python/pypaimon/filesystem/caching_file_io.py
index 8b10166461..3fad543d8d 100644
--- a/paimon-python/pypaimon/filesystem/caching_file_io.py
+++ b/paimon-python/pypaimon/filesystem/caching_file_io.py
@@ -436,6 +436,9 @@ class CachingFileIO(FileIO):
def try_to_write_atomic(self, *args, **kwargs):
return self._delegate.try_to_write_atomic(*args, **kwargs)
+ def create_blob_presigned_url(self, *args, **kwargs):
+ return self._delegate.create_blob_presigned_url(*args, **kwargs)
+
def write_parquet(self, *args, **kwargs):
return self._delegate.write_parquet(*args, **kwargs)
diff --git a/paimon-python/pypaimon/filesystem/oss_blob_presigner.py
b/paimon-python/pypaimon/filesystem/oss_blob_presigner.py
new file mode 100644
index 0000000000..3f02e3d6b6
--- /dev/null
+++ b/paimon-python/pypaimon/filesystem/oss_blob_presigner.py
@@ -0,0 +1,250 @@
+# 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.
+
+"""Materialize OSS BLOB ranges and create temporary GET URLs."""
+
+import hashlib
+import logging
+import posixpath
+import re
+from datetime import timedelta
+from urllib.parse import unquote, urlparse, urlsplit
+
+
+_LOGGER = logging.getLogger(__name__)
+
+_BLOB_FINGERPRINT_METADATA = "paimon-blob-descriptor-sha256"
+_BLOB_FINGERPRINT_HEADER = "x-oss-meta-" + _BLOB_FINGERPRINT_METADATA
+_BLOB_CONTENT_TYPE = "application/octet-stream"
+_BLOB_COPY_MIN_PART_SIZE = 100 * 1024 * 1024
+_MAX_MULTIPART_UPLOAD_PARTS = 10_000
+
+
+def create_presigned_url(
+ bucket, table_root, descriptor, validity, sse_headers=None,
+ has_security_token=False) -> str:
+ """Create a presigned URL with the same object layout as Java Paimon."""
+ endpoint = urlsplit(bucket.endpoint)
+ if endpoint.scheme.lower() != "https" or not endpoint.hostname:
+ raise ValueError("OSS BLOB presigning requires an HTTPS endpoint.")
+ validity_seconds = _validity_seconds(validity, has_security_token)
+ source = _validate_table_root(table_root, descriptor)
+ # Keep the compatibility URI rules in OssFileIO, which also owns bucket
+ # extraction for oss://AK:SK@endpoint/bucket/object-key URIs.
+ from pypaimon.filesystem.oss_file_io import OssFileIO
+ source_key = OssFileIO._extract_oss_object_key(source.geturl())
+ if not source_key:
+ raise ValueError("Blob descriptor URI must contain an OSS object key.")
+ sse_headers = dict(sse_headers or {})
+ fingerprint = hashlib.sha256(descriptor.serialize()).hexdigest()
+ parent_end = source_key.rfind('/') + 1
+ target_key = source_key[:parent_end] + "_bloburl_" + fingerprint
+
+ try:
+ target = _head_object_if_exists(bucket, target_key)
+ if not _matches(
+ target, descriptor.length, fingerprint, sse_headers):
+ source_metadata = bucket.head_object(source_key)
+ _validate_range(descriptor, source_metadata.content_length)
+ _materialize(
+ bucket,
+ source_key,
+ target_key,
+ descriptor,
+ fingerprint,
+ sse_headers,
+ )
+ target = bucket.head_object(target_key)
+ if not _matches(
+ target, descriptor.length, fingerprint, sse_headers):
+ raise OSError(
+ "Materialized blob object metadata does not match "
+ "descriptor.")
+
+ url = bucket.sign_url(
+ 'GET', target_key, validity_seconds, slash_safe=True)
+ _validate_presigned_url(bucket, url, target_key)
+ return url
+ except (OSError, ValueError):
+ raise
+ except Exception as error:
+ raise OSError("Failed to create blob presigned URL.") from error
+
+
+def _validity_seconds(validity, has_security_token=False) -> int:
+ if not isinstance(validity, timedelta):
+ raise TypeError(
+ "Blob presigned URL validity must be datetime.timedelta.")
+ if validity <= timedelta(0) or validity.microseconds != 0:
+ raise ValueError(
+ "Blob presigned URL validity must be positive whole seconds.")
+ seconds = validity.days * 24 * 60 * 60 + validity.seconds
+ maximum = 43200 if has_security_token else 604800
+ if seconds > maximum:
+ raise ValueError(
+ "OSS V4 presigned URL validity must not exceed {} seconds."
+ .format(maximum))
+ return seconds
+
+
+def _validate_table_root(table_root, descriptor):
+ root = urlparse(str(table_root))
+ blob = urlparse(descriptor.uri)
+ root_path = _normalize_path(root.path)
+ blob_path = _normalize_path(blob.path)
+ if ((root.scheme or '').lower() != (blob.scheme or '').lower()
+ or root.netloc != blob.netloc):
+ raise ValueError(
+ "Blob descriptor URI must use the same scheme and authority "
+ "as table root.")
+ child_prefix = root_path if root_path.endswith('/') else root_path + '/'
+ if not blob_path.startswith(child_prefix):
+ raise ValueError("Blob descriptor URI must be under table root.")
+ return blob._replace(path=blob_path)
+
+
+def _normalize_path(path):
+ normalized = posixpath.normpath(re.sub('/+', '/', path or '/'))
+ if path.startswith('/') and not normalized.startswith('/'):
+ normalized = '/' + normalized
+ return normalized
+
+
+def _head_object_if_exists(bucket, key):
+ try:
+ return bucket.head_object(key)
+ except Exception as error:
+ if getattr(error, 'code', None) in ('NoSuchKey', 'NoSuchObject'):
+ return None
+ raise
+
+
+def _matches(metadata, length, fingerprint, sse_headers) -> bool:
+ if metadata is None:
+ return False
+ headers = getattr(metadata, 'headers', {}) or {}
+ metadata_fingerprint = next(
+ (value for key, value in headers.items()
+ if key.lower() == _BLOB_FINGERPRINT_HEADER),
+ None,
+ )
+ return (
+ metadata.content_length == length
+ and metadata.content_type == _BLOB_CONTENT_TYPE
+ and metadata_fingerprint == fingerprint
+ and _matches_sse_headers(headers, sse_headers)
+ )
+
+
+def _matches_sse_headers(actual_headers, expected_headers) -> bool:
+ if not expected_headers:
+ return True
+ actual = {
+ str(key).lower(): value
+ for key, value in actual_headers.items()
+ }
+ case_insensitive_values = {
+ 'x-oss-server-side-encryption',
+ 'x-oss-server-side-data-encryption',
+ }
+ for key, expected in expected_headers.items():
+ normalized_key = str(key).lower()
+ found = actual.get(normalized_key)
+ if found is None:
+ return False
+ if normalized_key in case_insensitive_values:
+ if str(found).upper() != str(expected).upper():
+ return False
+ elif found != expected:
+ return False
+ return True
+
+
+def _validate_range(descriptor, source_length):
+ if (descriptor.offset < 0
+ or descriptor.length < 0
+ or descriptor.offset > source_length
+ or descriptor.length > source_length - descriptor.offset):
+ raise ValueError("Blob descriptor range is outside the source object.")
+
+
+def _materialize(
+ bucket,
+ source_key,
+ target_key,
+ descriptor,
+ fingerprint,
+ sse_headers):
+ import oss2
+
+ headers = dict(sse_headers)
+ headers.update({
+ 'Content-Type': _BLOB_CONTENT_TYPE,
+ _BLOB_FINGERPRINT_HEADER: fingerprint,
+ })
+ if descriptor.length == 0:
+ bucket.put_object(target_key, b'', headers=headers)
+ return
+
+ upload_id = None
+ try:
+ initiated = bucket.init_multipart_upload(target_key, headers=headers)
+ upload_id = initiated.upload_id
+ part_size = max(
+ _BLOB_COPY_MIN_PART_SIZE,
+ descriptor.length // _MAX_MULTIPART_UPLOAD_PARTS + 1,
+ )
+ parts = []
+ copied = 0
+ part_number = 1
+ while copied < descriptor.length:
+ size = min(part_size, descriptor.length - copied)
+ result = bucket.upload_part_copy(
+ bucket.bucket_name,
+ source_key,
+ (descriptor.offset + copied,
+ descriptor.offset + copied + size - 1),
+ target_key,
+ upload_id,
+ part_number,
+ )
+ parts.append(oss2.models.PartInfo(part_number, result.etag))
+ copied += size
+ part_number += 1
+ bucket.complete_multipart_upload(target_key, upload_id, parts)
+ upload_id = None
+ except Exception:
+ if upload_id is not None:
+ try:
+ bucket.abort_multipart_upload(target_key, upload_id)
+ except Exception:
+ _LOGGER.warning(
+ "Failed to abort OSS BLOB multipart upload %s for %s",
+ upload_id, target_key, exc_info=True)
+ raise
+
+
+def _validate_presigned_url(bucket, url, target_key):
+ endpoint = urlsplit(bucket.endpoint)
+ actual = urlsplit(url)
+ expected_host = bucket.bucket_name + '.' + (endpoint.hostname or '')
+ if (endpoint.scheme.lower() != 'https'
+ or actual.scheme.lower() != 'https'
+ or (actual.hostname or '').lower() != expected_host.lower()
+ or unquote(actual.path) != '/' + target_key):
+ raise OSError(
+ "OSS client generated a presigned URL for an invalid target.")
diff --git a/paimon-python/pypaimon/filesystem/oss_file_io.py
b/paimon-python/pypaimon/filesystem/oss_file_io.py
index 23b5859738..1dd0b40dc5 100644
--- a/paimon-python/pypaimon/filesystem/oss_file_io.py
+++ b/paimon-python/pypaimon/filesystem/oss_file_io.py
@@ -15,7 +15,7 @@
# specific language governing permissions and limitations
# under the License.
-"""OSS conditional metadata creation, reusing the existing Arrow/Jindo
FileIO."""
+"""OSS-specific operations, reusing the existing Arrow/Jindo FileIO."""
import re
from urllib.parse import urlparse
@@ -38,9 +38,7 @@ class OssFileIO(PyArrowFileIO):
if uri.scheme:
if uri.scheme != 'oss' or self._extract_oss_bucket(path) !=
self._oss_bucket:
raise ValueError("Atomic write must target the configured OSS
bucket")
- key = re.sub(r'/+', '/', uri.path).lstrip('/')
- if '@' in uri.netloc:
- key = key.partition('/')[2]
+ key = self._extract_oss_object_key(path)
path = 'oss://{}/{}'.format(self._oss_bucket, key)
else:
key = path
@@ -77,6 +75,33 @@ class OssFileIO(PyArrowFileIO):
finally:
session.session.close()
+ def create_blob_presigned_url(self, table_root, descriptor, validity) ->
str:
+ try:
+ import oss2
+ except ImportError as error:
+ raise ImportError(
+ "OSS BLOB presigning requires oss2. Install pypaimon[oss] "
+ "or pypaimon[jindo]."
+ ) from error
+
+ from pypaimon.filesystem.oss_blob_presigner import create_presigned_url
+
+ session = oss2.Session()
+ try:
+ bucket = self._create_oss_bucket(
+ session, self._extract_oss_bucket(descriptor.uri))
+ return create_presigned_url(
+ bucket,
+ table_root,
+ descriptor,
+ validity,
+ sse_headers=self._sse_headers(),
+ has_security_token=bool(
+ self.properties.get(OssOptions.OSS_SECURITY_TOKEN)),
+ )
+ finally:
+ session.session.close()
+
def _supports_atomic_write(self, bucket):
"""Cache the publication mode for this instance; failed queries remain
retryable."""
import oss2
@@ -97,7 +122,22 @@ class OssFileIO(PyArrowFileIO):
self._oss_bucket, versioning)
return self._atomic_write_supported
- def _create_oss_bucket(self, session):
+ @staticmethod
+ def _extract_oss_object_key(location) -> str:
+ """Return an OSS object key for standard and credential URI forms."""
+ uri = urlparse(location)
+ if uri.scheme and uri.scheme.lower() != 'oss':
+ raise ValueError("Not an OSS URI: {}".format(location))
+ key = re.sub(r'/+', '/', uri.path).lstrip('/')
+ netloc = uri.netloc or ''
+ if ((getattr(uri, 'username', None)
+ or getattr(uri, 'password', None))
+ or '@' in netloc):
+ # Legacy URI: oss://AK:SK@endpoint/bucket/object-key
+ key = key.partition('/')[2]
+ return key
+
+ def _create_oss_bucket(self, session, bucket_name=None):
"""Build the metadata client with one V4 credential path for both AK
and STS."""
import oss2
from oss2.credentials import StaticCredentialsProvider
@@ -122,7 +162,13 @@ class OssFileIO(PyArrowFileIO):
raise ValueError("Set fs.oss.region for OSS V4 signing when the
endpoint is not regional")
provider = StaticCredentialsProvider(access_key, secret_key, token)
auth = oss2.ProviderAuthV4(provider)
- return oss2.Bucket(auth, endpoint, self._oss_bucket, session=session,
region=region)
+ return oss2.Bucket(
+ auth,
+ endpoint,
+ bucket_name or self._oss_bucket,
+ session=session,
+ region=region,
+ )
def _sse_headers(self):
"""Match Java OSSFileIO's SSE resolution, including the native option
fallback."""
diff --git a/paimon-python/pypaimon/filesystem/resolving_file_io.py
b/paimon-python/pypaimon/filesystem/resolving_file_io.py
index e8f1c79d87..e4ca45394e 100644
--- a/paimon-python/pypaimon/filesystem/resolving_file_io.py
+++ b/paimon-python/pypaimon/filesystem/resolving_file_io.py
@@ -100,6 +100,10 @@ class ResolvingFileIO(FileIO):
def try_to_write_atomic(self, path: str, content: str) -> bool:
return self._get_fileio(path).try_to_write_atomic(path, content)
+ def create_blob_presigned_url(self, table_root, descriptor, validity) ->
str:
+ return self._get_fileio(descriptor.uri).create_blob_presigned_url(
+ table_root, descriptor, validity)
+
def get_file_size(self, path: str) -> int:
return self._get_fileio(path).get_file_size(path)
diff --git a/paimon-python/pypaimon/multimodal/blob_store.py
b/paimon-python/pypaimon/multimodal/blob_store.py
index bed1ff22a0..53831750b5 100644
--- a/paimon-python/pypaimon/multimodal/blob_store.py
+++ b/paimon-python/pypaimon/multimodal/blob_store.py
@@ -18,6 +18,7 @@
import io
import re
from dataclasses import dataclass
+from datetime import timedelta
from typing import BinaryIO, Dict, Iterable, List, Mapping, Optional, Sequence
from pypaimon.common.options.core_options import CoreOptions
@@ -58,6 +59,7 @@ class BlobObject:
columns: Dict[str, object]
file_io: object
range_header: Optional[str] = None
+ table_root: Optional[str] = None
@property
def size(self) -> int:
@@ -77,6 +79,17 @@ class BlobObject:
with self.open() as stream:
return stream.read()
+ def to_presigned_url(self, validity: timedelta) -> str:
+ """Create a temporary URL for this object, honoring its selected byte
range.
+
+ Requires a table-bound object and a FileIO that supports presigning.
+ """
+ if self.table_root is None:
+ raise ValueError("BlobObject must be bound to a table root for
presigning.")
+ descriptor = _descriptor_for_range(self.descriptor, self.range_header)
+ return self.file_io.create_blob_presigned_url(
+ self.table_root, descriptor, validity)
+
class BlobStore:
"""S3-like object facade over a Paimon multimodal table BLOB column."""
@@ -172,6 +185,7 @@ class BlobStore:
columns=info.columns,
file_io=self._raw_table.file_io,
range_header=range,
+ table_root=self._raw_table.table_path,
)
def head_object(
diff --git a/paimon-python/pypaimon/table/row/blob.py
b/paimon-python/pypaimon/table/row/blob.py
index fb700721de..f2d666b64d 100644
--- a/paimon-python/pypaimon/table/row/blob.py
+++ b/paimon-python/pypaimon/table/row/blob.py
@@ -550,6 +550,11 @@ class Blob(ABC):
def new_input_stream(self) -> BinaryIO:
pass
+ def to_presigned_url(self, file_io, table_root: str, validity) -> str:
+ """Create a temporary URL for this descriptor-backed BLOB."""
+ return file_io.create_blob_presigned_url(
+ table_root, self.to_descriptor(), validity)
+
@staticmethod
def from_data(data: bytes) -> 'Blob':
return BlobData(data)
diff --git a/paimon-python/pypaimon/tests/multimodal_table_test.py
b/paimon-python/pypaimon/tests/multimodal_table_test.py
index acca5be9d9..3d4de045ee 100644
--- a/paimon-python/pypaimon/tests/multimodal_table_test.py
+++ b/paimon-python/pypaimon/tests/multimodal_table_test.py
@@ -21,6 +21,8 @@ import os
import shutil
import tempfile
import unittest
+from dataclasses import replace
+from datetime import timedelta
from unittest.mock import patch
import pyarrow as pa
@@ -518,6 +520,25 @@ class MultimodalTableTest(unittest.TestCase):
cat = store.get_object("images/cat.jpg")
self.assertEqual(b"cat-image-v1", cat.read())
+ self.assertEqual(table.raw_table.table_path, cat.table_root)
+ with patch.object(cat.file_io, 'create_blob_presigned_url',
+ return_value="https://signed-url") as presign:
+ validity = timedelta(minutes=30)
+ self.assertEqual("https://signed-url",
cat.to_presigned_url(validity))
+ presign.assert_called_once_with(cat.table_root, cat.descriptor,
validity)
+ presign.reset_mock()
+ ranged = store.get_object("images/cat.jpg", range="bytes=1-4")
+ self.assertEqual("https://signed-url",
ranged.to_presigned_url(validity))
+ root, descriptor, expiry = presign.call_args[0]
+ self.assertEqual(cat.table_root, root)
+ self.assertEqual(cat.descriptor.uri, descriptor.uri)
+ self.assertEqual(cat.descriptor.offset + 1, descriptor.offset)
+ self.assertEqual(4, descriptor.length)
+ self.assertEqual(validity, expiry)
+ presign.reset_mock()
+ with self.assertRaisesRegex(ValueError, "bound to a table root"):
+ replace(cat, table_root=None).to_presigned_url(validity)
+ presign.assert_not_called()
self.assertEqual(b"at-i", store.get_object(
"images/cat.jpg", range="bytes=1-4").read())
clipped = store.get_object("images/cat.jpg", range="bytes=10-999")
diff --git a/paimon-python/pypaimon/tests/oss_blob_presigner_test.py
b/paimon-python/pypaimon/tests/oss_blob_presigner_test.py
new file mode 100644
index 0000000000..b0886a8122
--- /dev/null
+++ b/paimon-python/pypaimon/tests/oss_blob_presigner_test.py
@@ -0,0 +1,530 @@
+# 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 hashlib
+from datetime import timedelta
+from types import SimpleNamespace
+from unittest import mock
+
+import pytest
+
+from pypaimon.catalog.rest.rest_token_file_io import RESTTokenFileIO
+from pypaimon.common.file_io import FileIO
+from pypaimon.common.options import Options
+from pypaimon.filesystem.caching_file_io import CachingFileIO
+from pypaimon.filesystem.oss_blob_presigner import create_presigned_url
+from pypaimon.filesystem.oss_file_io import OssFileIO
+from pypaimon.filesystem.resolving_file_io import ResolvingFileIO
+from pypaimon.table.row.blob import Blob, BlobDescriptor
+
+oss2 = pytest.importorskip("oss2")
+
+
+_FINGERPRINT = (
+ "cdba7930b06372d607da30924e956ec0"
+ "c1e5f0a27eac8155b2d6a2973022f1a8"
+)
+_TARGET_KEY = "table/bucket-0/_bloburl_" + _FINGERPRINT
+
+
+def _descriptor(offset=10, length=20):
+ return BlobDescriptor(
+ "oss://bucket/table/bucket-0/source.blob", offset, length)
+
+
+def _metadata(length=20, fingerprint=_FINGERPRINT, sse_headers=None):
+ headers = {
+ "x-oss-meta-paimon-blob-descriptor-sha256": fingerprint,
+ }
+ headers.update(sse_headers or {})
+ return SimpleNamespace(
+ content_length=length,
+ content_type="application/octet-stream",
+ headers=headers,
+ )
+
+
+def _bucket(endpoint="https://oss-cn-hangzhou.aliyuncs.com"):
+ bucket = mock.MagicMock()
+ bucket.bucket_name = "bucket"
+ bucket.endpoint = endpoint
+ bucket.sign_url.return_value = (
+ endpoint.replace("oss-", "bucket.oss-")
+ + "/"
+ + _TARGET_KEY
+ + "?x-oss-signature=test"
+ )
+ return bucket
+
+
+def _missing():
+ return oss2.exceptions.ServerError(
+ 404, {}, b"", {"Code": "NoSuchKey", "Message": "missing"})
+
+
+def test_blob_api_delegates_to_file_io():
+ descriptor = _descriptor()
+ blob = Blob.from_descriptor(mock.MagicMock(), descriptor)
+ file_io = mock.MagicMock()
+ validity = timedelta(minutes=5)
+ file_io.create_blob_presigned_url.return_value = "https://example"
+
+ assert blob.to_presigned_url(
+ file_io, "oss://bucket/table", validity) == "https://example"
+ file_io.create_blob_presigned_url.assert_called_once_with(
+ "oss://bucket/table", descriptor, validity)
+
+
+def test_inline_blob_cannot_create_presigned_url():
+ file_io = mock.MagicMock()
+ with pytest.raises(RuntimeError, match="can not convert to descriptor"):
+ Blob.from_data(b"inline").to_presigned_url(
+ file_io, "oss://bucket/table", timedelta(minutes=5))
+ file_io.create_blob_presigned_url.assert_not_called()
+
+
[email protected]("token", [None, "sts-token"])
+def test_oss_file_io_uses_descriptor_bucket_and_closes_session(token):
+ descriptor = _descriptor()
+ validity = timedelta(minutes=5)
+ bucket = _bucket()
+ session = mock.MagicMock()
+ file_io = OssFileIO.__new__(OssFileIO)
+ file_io._create_oss_bucket = mock.MagicMock(return_value=bucket)
+ file_io.properties = Options({
+ 'fs.oss.server-side-encryption': 'AES256',
+ 'fs.oss.securityToken': token,
+ })
+
+ with mock.patch('oss2.Session', return_value=session), mock.patch(
+ 'pypaimon.filesystem.oss_blob_presigner.create_presigned_url',
+ return_value='https://example') as create:
+ assert file_io.create_blob_presigned_url(
+ 'oss://bucket/table', descriptor, validity) == 'https://example'
+
+ file_io._create_oss_bucket.assert_called_once_with(session, 'bucket')
+ create.assert_called_once_with(
+ bucket,
+ 'oss://bucket/table',
+ descriptor,
+ validity,
+ sse_headers={'x-oss-server-side-encryption': 'AES256'},
+ has_security_token=bool(token),
+ )
+ session.session.close.assert_called_once_with()
+
+
+def test_cache_hit_only_creates_fresh_url():
+ bucket = _bucket()
+ bucket.head_object.return_value = _metadata()
+
+ url = create_presigned_url(
+ bucket,
+ "oss://bucket/table",
+ _descriptor(),
+ timedelta(minutes=5),
+ )
+
+ assert url.startswith("https://bucket.oss-cn-hangzhou.aliyuncs.com/")
+ bucket.head_object.assert_called_once_with(_TARGET_KEY)
+ bucket.sign_url.assert_called_once_with(
+ 'GET', _TARGET_KEY, 300, slash_safe=True)
+ bucket.init_multipart_upload.assert_not_called()
+
+
+def test_materializes_descriptor_range_and_reuses_java_object_key():
+ bucket = _bucket()
+ bucket.head_object.side_effect = [
+ _missing(),
+ SimpleNamespace(content_length=100),
+ _metadata(),
+ ]
+ bucket.init_multipart_upload.return_value = SimpleNamespace(
+ upload_id="upload-id")
+ bucket.upload_part_copy.return_value = SimpleNamespace(etag="etag")
+
+ create_presigned_url(
+ bucket,
+ "oss://bucket/table",
+ _descriptor(),
+ timedelta(seconds=30),
+ )
+
+ assert bucket.head_object.call_args_list == [
+ mock.call(_TARGET_KEY),
+ mock.call("table/bucket-0/source.blob"),
+ mock.call(_TARGET_KEY),
+ ]
+ bucket.init_multipart_upload.assert_called_once_with(
+ _TARGET_KEY,
+ headers={
+ "Content-Type": "application/octet-stream",
+ "x-oss-meta-paimon-blob-descriptor-sha256": _FINGERPRINT,
+ },
+ )
+ bucket.upload_part_copy.assert_called_once_with(
+ "bucket",
+ "table/bucket-0/source.blob",
+ (10, 29),
+ _TARGET_KEY,
+ "upload-id",
+ 1,
+ )
+ parts = bucket.complete_multipart_upload.call_args[0][2]
+ assert [(part.part_number, part.etag) for part in parts] == [(1, "etag")]
+
+
[email protected]("sse_headers", [
+ {"x-oss-server-side-encryption": "AES256"},
+ {
+ "x-oss-server-side-encryption": "KMS",
+ "x-oss-server-side-encryption-key-id": "cmk-123",
+ },
+])
+def test_materialization_preserves_server_side_encryption(sse_headers):
+ bucket = _bucket()
+ bucket.head_object.side_effect = [
+ _missing(),
+ SimpleNamespace(content_length=100),
+ _metadata(sse_headers=sse_headers),
+ ]
+ bucket.init_multipart_upload.return_value = SimpleNamespace(
+ upload_id="upload-id")
+ bucket.upload_part_copy.return_value = SimpleNamespace(etag="etag")
+
+ create_presigned_url(
+ bucket,
+ "oss://bucket/table",
+ _descriptor(),
+ timedelta(seconds=30),
+ sse_headers=sse_headers,
+ )
+
+ expected_headers = dict(sse_headers)
+ expected_headers.update({
+ "Content-Type": "application/octet-stream",
+ "x-oss-meta-paimon-blob-descriptor-sha256": _FINGERPRINT,
+ })
+ bucket.init_multipart_upload.assert_called_once_with(
+ _TARGET_KEY, headers=expected_headers)
+
+
[email protected]("actual_sse", [
+ {},
+ {"x-oss-server-side-encryption": "AES256"},
+], ids=["unencrypted", "different-method"])
+def test_cache_rejects_object_with_incompatible_server_side_encryption(
+ actual_sse):
+ expected_sse = {
+ "x-oss-server-side-encryption": "KMS",
+ "x-oss-server-side-encryption-key-id": "cmk-123",
+ }
+ bucket = _bucket()
+ bucket.head_object.side_effect = [
+ _metadata(sse_headers=actual_sse),
+ SimpleNamespace(content_length=100),
+ _metadata(sse_headers=expected_sse),
+ ]
+ bucket.init_multipart_upload.return_value = SimpleNamespace(
+ upload_id="upload-id")
+ bucket.upload_part_copy.return_value = SimpleNamespace(etag="etag")
+
+ create_presigned_url(
+ bucket,
+ "oss://bucket/table",
+ _descriptor(),
+ timedelta(seconds=30),
+ sse_headers=expected_sse,
+ )
+
+ bucket.init_multipart_upload.assert_called_once()
+
+
+def test_cache_reuses_object_with_matching_server_side_encryption():
+ expected_sse = {
+ "x-oss-server-side-encryption": "kms",
+ "x-oss-server-side-encryption-key-id": "cmk-123",
+ }
+ bucket = _bucket()
+ bucket.head_object.return_value = _metadata(sse_headers={
+ "X-Oss-Server-Side-Encryption": "KMS",
+ "X-Oss-Server-Side-Encryption-Key-Id": "cmk-123",
+ })
+
+ create_presigned_url(
+ bucket,
+ "oss://bucket/table",
+ _descriptor(),
+ timedelta(seconds=30),
+ sse_headers=expected_sse,
+ )
+
+ bucket.init_multipart_upload.assert_not_called()
+
+
+def test_legacy_oss_uri_strips_bucket_from_source_and_target_keys():
+ descriptor = BlobDescriptor(
+ "oss://AK:SK@endpoint/bucket/table/bucket-0/source.blob", 10, 20)
+ fingerprint = hashlib.sha256(descriptor.serialize()).hexdigest()
+ target_key = "table/bucket-0/_bloburl_" + fingerprint
+ bucket = _bucket()
+ bucket.sign_url.return_value = (
+ "https://bucket.oss-cn-hangzhou.aliyuncs.com/"
+ + target_key
+ + "?x-oss-signature=test"
+ )
+ bucket.head_object.side_effect = [
+ _missing(),
+ SimpleNamespace(content_length=100),
+ _metadata(fingerprint=fingerprint),
+ ]
+ bucket.init_multipart_upload.return_value = SimpleNamespace(
+ upload_id="upload-id")
+ bucket.upload_part_copy.return_value = SimpleNamespace(etag="etag")
+
+ create_presigned_url(
+ bucket,
+ "oss://AK:SK@endpoint/bucket/table",
+ descriptor,
+ timedelta(seconds=30),
+ )
+
+ assert bucket.head_object.call_args_list == [
+ mock.call(target_key),
+ mock.call("table/bucket-0/source.blob"),
+ mock.call(target_key),
+ ]
+ bucket.upload_part_copy.assert_called_once_with(
+ "bucket",
+ "table/bucket-0/source.blob",
+ (10, 29),
+ target_key,
+ "upload-id",
+ 1,
+ )
+
+
+def test_zero_length_descriptor_uses_put_object():
+ descriptor = _descriptor(offset=100, length=0)
+ fingerprint = hashlib.sha256(descriptor.serialize()).hexdigest()
+ target_key = "table/bucket-0/_bloburl_" + fingerprint
+ bucket = _bucket()
+ bucket.sign_url.return_value = (
+ "https://bucket.oss-cn-hangzhou.aliyuncs.com/"
+ + target_key
+ + "?x-oss-signature=test"
+ )
+ sse_headers = {"x-oss-server-side-encryption": "AES256"}
+ bucket.head_object.side_effect = [
+ _missing(),
+ SimpleNamespace(content_length=100),
+ _metadata(
+ length=0,
+ fingerprint=fingerprint,
+ sse_headers=sse_headers,
+ ),
+ ]
+
+ create_presigned_url(
+ bucket,
+ "oss://bucket/table",
+ descriptor,
+ timedelta(minutes=5),
+ sse_headers=sse_headers,
+ )
+
+ expected_headers = dict(sse_headers)
+ expected_headers.update({
+ "Content-Type": "application/octet-stream",
+ "x-oss-meta-paimon-blob-descriptor-sha256": fingerprint,
+ })
+ bucket.put_object.assert_called_once_with(
+ target_key,
+ b'',
+ headers=expected_headers,
+ )
+ bucket.init_multipart_upload.assert_not_called()
+
+
[email protected]("descriptor", [
+ _descriptor(offset=-1, length=20),
+ _descriptor(offset=10, length=-1),
+ _descriptor(offset=90, length=20),
+])
+def test_rejects_invalid_source_range(descriptor):
+ bucket = _bucket()
+ bucket.head_object.side_effect = [
+ _missing(), SimpleNamespace(content_length=100)]
+ with pytest.raises(ValueError, match="range is outside"):
+ create_presigned_url(
+ bucket,
+ "oss://bucket/table",
+ descriptor,
+ timedelta(minutes=5),
+ )
+ bucket.init_multipart_upload.assert_not_called()
+
+
[email protected]("table_root", [
+ "s3://bucket/table",
+ "oss://other/table",
+ "oss://bucket/other",
+ "oss://bucket/table-sibling",
+])
+def test_rejects_descriptor_outside_table_root(table_root):
+ with pytest.raises(
+ ValueError, match="scheme and authority|under table root"):
+ create_presigned_url(
+ _bucket(), table_root, _descriptor(), timedelta(minutes=5))
+
+
[email protected]("validity", [
+ timedelta(0),
+ timedelta(seconds=-1),
+ timedelta(microseconds=1),
+])
+def test_validity_must_be_positive_whole_seconds(validity):
+ with pytest.raises(ValueError, match="positive whole seconds"):
+ create_presigned_url(
+ _bucket(), "oss://bucket/table", _descriptor(), validity)
+
+
+def test_internal_endpoint_is_preserved():
+ bucket = _bucket("https://oss-cn-hangzhou-internal.aliyuncs.com")
+ bucket.head_object.return_value = _metadata()
+
+ url = create_presigned_url(
+ bucket,
+ "oss://bucket/table",
+ _descriptor(),
+ timedelta(minutes=5),
+ )
+
+ assert
url.startswith("https://bucket.oss-cn-hangzhou-internal.aliyuncs.com/")
+ assert url == bucket.sign_url.return_value
+
+
[email protected]("endpoint, generated_endpoint", [
+ ("https://oss-cn-hangzhou-internal.aliyuncs.com",
+ "https://oss-cn-hangzhou.aliyuncs.com"),
+ ("https://oss-cn-hangzhou.aliyuncs.com",
+ "https://oss-cn-hangzhou-internal.aliyuncs.com"),
+])
+def test_rejects_url_with_different_endpoint(endpoint, generated_endpoint):
+ bucket = _bucket(endpoint)
+ bucket.head_object.return_value = _metadata()
+ bucket.sign_url.return_value =
_bucket(generated_endpoint).sign_url.return_value
+ with pytest.raises(OSError, match="invalid target"):
+ create_presigned_url(
+ bucket, "oss://bucket/table", _descriptor(), timedelta(minutes=5))
+
+
+def test_rejects_non_https_endpoint_before_any_remote_call():
+ bucket = _bucket("http://oss-cn-hangzhou.aliyuncs.com")
+ bucket.head_object.return_value = _metadata()
+ with pytest.raises(ValueError, match="HTTPS endpoint"):
+ create_presigned_url(
+ bucket,
+ "oss://bucket/table",
+ _descriptor(),
+ timedelta(minutes=5),
+ )
+
+ assert bucket.mock_calls == []
+
+
+def test_failed_materialization_aborts_upload_and_wraps_error():
+ bucket = _bucket()
+ bucket.head_object.side_effect = [
+ _missing(), SimpleNamespace(content_length=100)]
+ bucket.init_multipart_upload.return_value = SimpleNamespace(
+ upload_id="upload-id")
+ bucket.upload_part_copy.side_effect = RuntimeError("copy failed")
+
+ with pytest.raises(OSError, match="Failed to create") as caught:
+ create_presigned_url(
+ bucket,
+ "oss://bucket/table",
+ _descriptor(),
+ timedelta(minutes=5),
+ )
+
+ assert isinstance(caught.value.__cause__, RuntimeError)
+ bucket.abort_multipart_upload.assert_called_once_with(
+ _TARGET_KEY, "upload-id")
+
+
+def test_file_io_wrappers_delegate_presigning():
+ descriptor = _descriptor()
+ validity = timedelta(minutes=5)
+ delegate = mock.MagicMock(spec=FileIO)
+ delegate.create_blob_presigned_url.return_value = "https://example"
+
+ caching = CachingFileIO(delegate, None)
+ assert caching.create_blob_presigned_url(
+ "oss://bucket/table", descriptor, validity) == "https://example"
+
+ resolving = ResolvingFileIO(Options({}))
+ with mock.patch.object(
+ resolving, '_get_fileio', return_value=delegate) as get_file_io:
+ assert resolving.create_blob_presigned_url(
+ "oss://bucket/table", descriptor, validity) == "https://example"
+ get_file_io.assert_called_once_with(descriptor.uri)
+
+ rest = RESTTokenFileIO.__new__(RESTTokenFileIO)
+ rest.path = "oss://bucket/table"
+ with mock.patch.object(
+ rest, '_file_io_with_token', return_value=(delegate, None)):
+ assert rest.create_blob_presigned_url(
+ "oss://bucket/table", descriptor, validity) == "https://example"
+
+
[email protected]("has_security_token, maximum", [
+ (False, 604800), (True, 43200),
+])
+def test_validity_upper_bound_before_any_remote_call(has_security_token,
maximum):
+ bucket = _bucket()
+ with pytest.raises(ValueError, match="must not exceed"):
+ create_presigned_url(
+ bucket, "oss://bucket/table", _descriptor(),
+ timedelta(seconds=maximum + 1),
+ has_security_token=has_security_token)
+ assert bucket.mock_calls == []
+ bucket.head_object.return_value = _metadata()
+ create_presigned_url(
+ bucket, "oss://bucket/table", _descriptor(),
+ timedelta(seconds=maximum), has_security_token=has_security_token)
+ bucket.sign_url.assert_called_once_with(
+ 'GET', _TARGET_KEY, maximum, slash_safe=True)
+
+
+def test_abort_failure_is_logged_without_replacing_copy_error(caplog):
+ bucket = _bucket()
+ bucket.head_object.side_effect = [
+ _missing(), SimpleNamespace(content_length=100)]
+ bucket.init_multipart_upload.return_value =
SimpleNamespace(upload_id="upload-id")
+ copy_error = RuntimeError("copy failed")
+ abort_error = RuntimeError("abort failed")
+ bucket.upload_part_copy.side_effect = copy_error
+ bucket.abort_multipart_upload.side_effect = abort_error
+ with pytest.raises(OSError) as caught:
+ create_presigned_url(
+ bucket, "oss://bucket/table", _descriptor(), timedelta(minutes=5))
+ assert caught.value.__cause__ is copy_error
+ record = next(r for r in caplog.records if "Failed to abort" in r.message)
+ assert record.exc_info[1] is abort_error
+ assert "upload-id" in record.message
diff --git a/paimon-python/pypaimon/tests/rest/rest_token_file_io_test.py
b/paimon-python/pypaimon/tests/rest/rest_token_file_io_test.py
index cf80fc95d1..4e94b21d72 100644
--- a/paimon-python/pypaimon/tests/rest/rest_token_file_io_test.py
+++ b/paimon-python/pypaimon/tests/rest/rest_token_file_io_test.py
@@ -20,6 +20,7 @@ import pickle
import tempfile
import time
import unittest
+from datetime import timedelta
from unittest.mock import patch, MagicMock
from pypaimon.catalog.rest.rest_token_file_io import RESTTokenFileIO
@@ -29,6 +30,7 @@ from pypaimon.common.identifier import Identifier
from pypaimon.common.options import Options
from pypaimon.common.options.config import CatalogOptions, OssOptions
from pypaimon.filesystem.local_file_io import LocalFileIO
+from pypaimon.table.row.blob import BlobDescriptor
class RESTTokenFileIOTest(unittest.TestCase):
@@ -47,6 +49,125 @@ class RESTTokenFileIOTest(unittest.TestCase):
if os.path.exists(self.temp_dir):
shutil.rmtree(self.temp_dir)
+ def test_blob_presigned_url_bound_table_root(self):
+ root = "oss://bucket/table-a"
+ file_io = RESTTokenFileIO(self.identifier, root, self.catalog_options)
+ descriptor = BlobDescriptor(root + "/data/video.blob", 10, 20)
+ validity = timedelta(minutes=30)
+ delegate = MagicMock()
+ delegate.create_blob_presigned_url.return_value = "https://signed-url"
+ with patch.object(
+ file_io, '_file_io_with_token',
+ return_value=(delegate, None)):
+ self.assertEqual(
+ file_io.create_blob_presigned_url(root, descriptor, validity),
+ "https://signed-url")
+ delegate.create_blob_presigned_url.assert_called_once_with(
+ root, descriptor, validity)
+
+ def test_blob_presigned_url_rejects_other_table_before_resolving_io(self):
+ file_io = RESTTokenFileIO(
+ self.identifier, "oss://bucket/table-a", self.catalog_options)
+ other_root = "oss://bucket/table-b"
+ descriptor = BlobDescriptor(other_root + "/data/video.blob", 10, 20)
+ with patch.object(file_io, '_file_io_with_token') as resolve:
+ with self.assertRaisesRegex(ValueError, "bound table root"):
+ file_io.create_blob_presigned_url(
+ other_root, descriptor, timedelta(minutes=30))
+ resolve.assert_not_called()
+
+ def test_presigned_url_refreshes_credentials_for_requested_lifetime(self):
+ self._check_presigned_url_lifetime(3, 4, True)
+
+ def test_presigned_url_reuses_credentials_for_short_validity(self):
+ self._check_presigned_url_lifetime(0.5, 4, False)
+
+ def test_presigned_url_accepts_when_one_second_exceeds_validity(self):
+ self._check_presigned_url_lifetime(
+ 1.5, 3, False, current_token_extra_seconds=1)
+
+ def test_presigned_url_rejects_insufficient_refreshed_lifetime(self):
+ self._check_presigned_url_lifetime(3, 2, True, rejected=True)
+
+ def _check_presigned_url_lifetime(
+ self, requested_hours, refreshed_hours, should_refresh,
rejected=False,
+ current_token_extra_seconds=None):
+ root = "oss://bucket/table"
+ file_io = RESTTokenFileIO(self.identifier, root, self.catalog_options)
+ now = 1700000000
+ token_properties = {OssOptions.OSS_SECURITY_TOKEN.key(): "test-token"}
+ if current_token_extra_seconds is None:
+ old_expiry = now + 2 * 3600
+ else:
+ old_expiry = (
+ now + requested_hours * 3600 + current_token_extra_seconds)
+ old_token = RESTToken(token_properties, int(old_expiry * 1000))
+ new_token = RESTToken(token_properties, (now + refreshed_hours * 3600)
* 1000)
+ file_io.token = old_token
+ descriptor = BlobDescriptor(root + "/video.blob", 0, 10)
+ validity = timedelta(hours=requested_hours)
+
+ def refresh():
+ file_io.token = new_token
+
+ with patch('pypaimon.catalog.rest.rest_token_file_io.time.time',
return_value=now), \
+ patch.object(file_io, '_build_cache_key',
return_value='presigning-test'), \
+ patch.object(file_io, '_get_cached_token',
return_value=old_token), \
+ patch.object(file_io, '_set_cached_token') as cache_token, \
+ patch.object(file_io, 'refresh_token', side_effect=refresh) as
refresh_token, \
+ patch('pypaimon.catalog.rest.rest_token_file_io.FileIO.get')
as get_io:
+ presign = get_io.return_value.create_blob_presigned_url
+ presign.side_effect = ["https://first-url",
"https://refreshed-url"]
+ if rejected:
+ with self.assertRaisesRegex(ValueError, "credential lifetime
after refresh"):
+ file_io.create_blob_presigned_url(root, descriptor,
validity)
+ get_io.assert_not_called()
+ else:
+ self.assertEqual("https://first-url",
file_io.create_blob_presigned_url(
+ root, descriptor, validity))
+ self.assertEqual(1, presign.call_count)
+ presign.assert_called_with(root, descriptor, validity)
+ self.assertEqual(int(should_refresh), refresh_token.call_count)
+ if should_refresh:
+
cache_token.assert_called_once_with(file_io._build_cache_key(), new_token)
+ else:
+ cache_token.assert_not_called()
+
+ def test_presigned_url_refreshes_and_resigns_after_materialization(self):
+ root = "oss://bucket/table"
+ file_io = RESTTokenFileIO(self.identifier, root, self.catalog_options)
+ now = [1700000000]
+ token_properties = {OssOptions.OSS_SECURITY_TOKEN.key(): "test-token"}
+ old_token = RESTToken(token_properties, (now[0] + 2 * 3600) * 1000)
+ new_token = RESTToken(token_properties, (now[0] + 4 * 3600) * 1000)
+ file_io.token = old_token
+ descriptor = BlobDescriptor(root + "/video.blob", 0, 10)
+ validity = timedelta(minutes=30)
+
+ def refresh():
+ file_io.token = new_token
+
+ def presign(*_):
+ if now[0] == 1700000000:
+ now[0] += 2 * 3600
+ return "https://first-url"
+ return "https://refreshed-url"
+
+ with patch('pypaimon.catalog.rest.rest_token_file_io.time.time',
+ side_effect=lambda: now[0]), \
+ patch.object(file_io, '_build_cache_key',
return_value='presigning-test'), \
+ patch.object(file_io, '_get_cached_token',
return_value=old_token), \
+ patch.object(file_io, '_set_cached_token'), \
+ patch.object(file_io, 'refresh_token', side_effect=refresh) as
refresh_token, \
+ patch('pypaimon.catalog.rest.rest_token_file_io.FileIO.get')
as get_io:
+ get_io.return_value.create_blob_presigned_url.side_effect = presign
+ self.assertEqual(
+ "https://refreshed-url",
+ file_io.create_blob_presigned_url(root, descriptor, validity))
+ self.assertEqual(
+ 2, get_io.return_value.create_blob_presigned_url.call_count)
+ refresh_token.assert_called_once_with()
+
def test_new_output_stream_path_conversion_and_parent_creation(self):
"""Test new_output_stream correctly handles URI paths and creates
parent directories."""
with patch.object(RESTTokenFileIO, 'try_to_refresh_token'):