From 31246d9fd2df61f7ed867e0253c4c392765ef595 Mon Sep 17 00:00:00 2001 From: Matthieu Dufour Date: Mon, 7 Oct 2024 15:46:03 +0100 Subject: [PATCH] feat(dcp): add support for S3 `CopyObject` API Add support for S3 `CopyObject` API, binding Python and Rust clients together. Bump versions for mountpoint-s3-client and mountpoint-s3-crt (required to use the `CopyObject` API). --- .gitignore | 3 + .../s3torchconnector/_s3client/_s3client.py | 8 ++ s3torchconnector/tst/unit/test_s3_client.py | 14 +++ s3torchconnectorclient/Cargo.lock | 14 +-- s3torchconnectorclient/Cargo.toml | 4 +- s3torchconnectorclient/pyproject.toml | 4 +- .../_mountpoint_s3_client.pyi | 3 + .../python/tst/integration/conftest.py | 91 +++++++++++-------- .../test_mountpoint_s3_integration.py | 87 +++++++++++++++++- .../tst/unit/test_mountpoint_s3_client.py | 76 +++++++++++++++- .../rust/src/mountpoint_s3_client.rs | 8 +- .../rust/src/mountpoint_s3_client_inner.rs | 12 ++- 12 files changed, 267 insertions(+), 57 deletions(-) diff --git a/.gitignore b/.gitignore index cd03184f..b4acc242 100644 --- a/.gitignore +++ b/.gitignore @@ -30,5 +30,8 @@ venv/ *.egg multirun/ +# Unit test / coverage reports +.hypothesis/ + # Prevent publishing file with third party licenses THIRD-PARTY-LICENSES diff --git a/s3torchconnector/src/s3torchconnector/_s3client/_s3client.py b/s3torchconnector/src/s3torchconnector/_s3client/_s3client.py index 4815496e..71ee13cb 100644 --- a/s3torchconnector/src/s3torchconnector/_s3client/_s3client.py +++ b/s3torchconnector/src/s3torchconnector/_s3client/_s3client.py @@ -130,3 +130,11 @@ def head_object(self, bucket: str, key: str) -> ObjectInfo: def delete_object(self, bucket: str, key: str) -> None: log.debug(f"DeleteObject s3://{bucket}/{key}") self._client.delete_object(bucket, key) + + def copy_object( + self, src_bucket: str, src_key: str, dst_bucket: str, dst_key: str + ) -> None: + log.debug( + f"CopyObject s3://{src_bucket}/{src_key} to s3://{dst_bucket}/{dst_key}" + ) + return self._client.copy_object(src_bucket, src_key, dst_bucket, dst_key) diff --git a/s3torchconnector/tst/unit/test_s3_client.py b/s3torchconnector/tst/unit/test_s3_client.py index e9b8b057..73cea5ac 100644 --- a/s3torchconnector/tst/unit/test_s3_client.py +++ b/s3torchconnector/tst/unit/test_s3_client.py @@ -60,6 +60,20 @@ def test_list_objects_log(s3_client: S3Client, caplog): assert f"ListObjects {S3_URI}" in caplog.messages +def test_delete_object_log(s3_client: S3Client, caplog): + with caplog.at_level(logging.DEBUG): + s3_client.delete_object(TEST_BUCKET, TEST_KEY) + assert f"DeleteObject {S3_URI}" in caplog.messages + + +def test_copy_object_log(s3_client: S3Client, caplog): + dst_bucket, dst_key = "dst_bucket", "dst_key" + + with caplog.at_level(logging.DEBUG): + s3_client.copy_object(TEST_BUCKET, TEST_KEY, dst_bucket, dst_key) + assert f"CopyObject {S3_URI} to s3://{dst_bucket}/{dst_key}" in caplog.messages + + def test_s3_client_default_user_agent(): s3_client = S3Client(region=TEST_REGION) expected_user_agent = f"s3torchconnector/{__version__}" diff --git a/s3torchconnectorclient/Cargo.lock b/s3torchconnectorclient/Cargo.lock index 41413740..0d874075 100644 --- a/s3torchconnectorclient/Cargo.lock +++ b/s3torchconnectorclient/Cargo.lock @@ -643,9 +643,9 @@ checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" [[package]] name = "mountpoint-s3-client" -version = "0.10.0" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b3cecabd371f8de97731e118e6fb2a8677bdcefe4e6e90f5a964a1c654b3ef7" +checksum = "2469bf1d23727d14e3d94f6c4b854aa45840d9a768cb74598b7b26773229a97d" dependencies = [ "async-io", "async-lock", @@ -678,9 +678,9 @@ dependencies = [ [[package]] name = "mountpoint-s3-crt" -version = "0.9.0" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9f1a24ac11fee585a1b8c55cd30af4c611c920856cafffcf9564b9c9eca36d7" +checksum = "7b86188158f1d2d0582683789c0ecd1edd45581f84bc665e4c4edeb60984954e" dependencies = [ "async-channel", "futures", @@ -694,9 +694,9 @@ dependencies = [ [[package]] name = "mountpoint-s3-crt-sys" -version = "0.9.0" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb95240e96c865dc20798d2d1cbe895ee327ddf6400b3378d343c19c48699baa" +checksum = "47e802f03ec1096a166dce1a61e686a115f01c568ad87346a2094fb2ed07141c" dependencies = [ "bindgen", "cc", @@ -1105,7 +1105,7 @@ checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f" [[package]] name = "s3torchconnectorclient" -version = "1.2.5" +version = "1.2.6" dependencies = [ "built", "futures", diff --git a/s3torchconnectorclient/Cargo.toml b/s3torchconnectorclient/Cargo.toml index 91b28104..6c3bc87b 100644 --- a/s3torchconnectorclient/Cargo.toml +++ b/s3torchconnectorclient/Cargo.toml @@ -19,8 +19,8 @@ built = "0.7" pyo3 = { version = "0.19.2" } pyo3-log = "0.8.3" futures = "0.3.28" -mountpoint-s3-client = { version = "0.10.0", features = ["mock"] } -mountpoint-s3-crt = "0.9.0" +mountpoint-s3-client = { version = "0.11.0", features = ["mock"] } +mountpoint-s3-crt = "0.10.0" log = "0.4.20" tracing = { version = "0.1.40", default-features = false, features = ["std", "log"] } tracing-subscriber = { version = "0.3.18", features = ["fmt", "env-filter"]} diff --git a/s3torchconnectorclient/pyproject.toml b/s3torchconnectorclient/pyproject.toml index 9866480e..f784a213 100644 --- a/s3torchconnectorclient/pyproject.toml +++ b/s3torchconnectorclient/pyproject.toml @@ -25,12 +25,14 @@ dependencies = [] [project.optional-dependencies] test = [ + "boto3", "pytest", "pytest-timeout", "hypothesis", "flake8", "black", - "mypy" + "mypy", + "Pillow" ] [tool.setuptools.packages] diff --git a/s3torchconnectorclient/python/src/s3torchconnectorclient/_mountpoint_s3_client.pyi b/s3torchconnectorclient/python/src/s3torchconnectorclient/_mountpoint_s3_client.pyi index e38beea6..ac269659 100644 --- a/s3torchconnectorclient/python/src/s3torchconnectorclient/_mountpoint_s3_client.pyi +++ b/s3torchconnectorclient/python/src/s3torchconnectorclient/_mountpoint_s3_client.pyi @@ -35,6 +35,9 @@ class MountpointS3Client: ) -> ListObjectStream: ... def head_object(self, bucket: str, key: str) -> ObjectInfo: ... def delete_object(self, bucket: str, key: str) -> None: ... + def copy_object( + self, src_bucket: str, src_key: str, dst_bucket: str, dst_key: str + ) -> None: ... class MockMountpointS3Client: throughput_target_gbps: float diff --git a/s3torchconnectorclient/python/tst/integration/conftest.py b/s3torchconnectorclient/python/tst/integration/conftest.py index 9095ad64..366728d7 100644 --- a/s3torchconnectorclient/python/tst/integration/conftest.py +++ b/s3torchconnectorclient/python/tst/integration/conftest.py @@ -4,6 +4,8 @@ import io import os import random +from dataclasses import dataclass, field +from typing import Optional import boto3 import numpy as np @@ -18,33 +20,28 @@ def getenv(var: str, optional: bool = False) -> str: return v -class BucketPrefixFixture(object): +@dataclass +class BucketPrefixFixture: """An S3 bucket/prefix and its contents for use in a single unit test. The prefix will be unique to this instance, so other concurrent tests won't affect its state.""" - region: str - bucket: str - prefix: str - storage_class: str = None - endpoint_url: str = None - - def __init__( - self, - region: str, - bucket: str, - prefix: str, - storage_class: str = None, - endpoint_url: str = None, - ): - self.bucket = bucket - self.prefix = prefix - self.region = region - self.storage_class = storage_class - self.endpoint_url = endpoint_url - self.contents = {} - session = boto3.Session(region_name=region) + name: str + + region: str = getenv("CI_REGION") + bucket: str = getenv("CI_BUCKET") + prefix: str = getenv("CI_PREFIX") + storage_class: Optional[str] = getenv("CI_STORAGE_CLASS", optional=True) + endpoint_url: Optional[str] = getenv("CI_CUSTOM_ENDPOINT_URL", optional=True) + contents: dict = field(default_factory=dict) + + def __post_init__(self): + assert self.prefix == "" or self.prefix.endswith("/") + session = boto3.Session(region_name=self.region) self.s3 = session.client("s3") + nonce = random.randrange(2**64) + self.prefix = f"{self.prefix}{self.name}/{nonce}/" + @property def s3_uri(self): return f"s3://{self.bucket}/{self.prefix}" @@ -55,6 +52,10 @@ def add(self, key: str, contents: bytes, **kwargs): self.s3.put_object(Bucket=self.bucket, Key=full_key, Body=contents, **kwargs) self.contents[full_key] = contents + def remove(self, key: str): + full_key = f"{self.prefix}{key}" + self.s3.delete_object(Bucket=self.bucket, Key=full_key) + def __getitem__(self, index): return self.contents[index] @@ -62,19 +63,28 @@ def __iter__(self): return iter(self.contents) -def get_test_bucket_prefix(name: str) -> BucketPrefixFixture: - """Create a new bucket/prefix fixture for the given test name.""" - bucket = getenv("CI_BUCKET") - prefix = getenv("CI_PREFIX") - region = getenv("CI_REGION") - storage_class = getenv("CI_STORAGE_CLASS", optional=True) - endpoint_url = getenv("CI_CUSTOM_ENDPOINT_URL", optional=True) - assert prefix == "" or prefix.endswith("/") +@dataclass +class CopyBucketFixture(BucketPrefixFixture): + src_key: str = "src.txt" + dst_key: str = "dst.txt" + + @property + def full_src_key(self): + return self.prefix + self.src_key + + @property + def full_dst_key(self): + return self.prefix + self.dst_key + + +def get_test_copy_bucket_fixture(name: str) -> CopyBucketFixture: + copy_bucket_fixture = CopyBucketFixture(name=name) - nonce = random.randrange(2**64) - prefix = f"{prefix}{name}/{nonce}/" + # set up / teardown + copy_bucket_fixture.add(copy_bucket_fixture.src_key, b"Hello, World!\n") + copy_bucket_fixture.remove(copy_bucket_fixture.dst_key) - return BucketPrefixFixture(region, bucket, prefix, storage_class, endpoint_url) + return copy_bucket_fixture @pytest.fixture @@ -82,7 +92,7 @@ def image_directory(request) -> BucketPrefixFixture: """Create a bucket/prefix fixture that contains a directory of random JPG image files.""" NUM_IMAGES = 10 IMAGE_SIZE = 100 - fixture = get_test_bucket_prefix(f"{request.node.name}/image_directory") + fixture = BucketPrefixFixture(f"{request.node.name}/image_directory") for i in range(NUM_IMAGES): data = np.random.randint(0, 256, IMAGE_SIZE * IMAGE_SIZE * 3, np.uint8) data = data.reshape(IMAGE_SIZE, IMAGE_SIZE, 3) @@ -100,23 +110,28 @@ def image_directory(request) -> BucketPrefixFixture: @pytest.fixture def sample_directory(request) -> BucketPrefixFixture: - fixture = get_test_bucket_prefix(f"{request.node.name}/sample_files") + fixture = BucketPrefixFixture(f"{request.node.name}/sample_files") fixture.add("hello_world.txt", b"Hello, World!\n") return fixture @pytest.fixture def put_object_tests_directory(request) -> BucketPrefixFixture: - fixture = get_test_bucket_prefix(f"{request.node.name}/put_integration_tests") + fixture = BucketPrefixFixture(f"{request.node.name}/put_integration_tests") fixture.add("to_overwrite.txt", b"before") return fixture @pytest.fixture def checkpoint_directory(request) -> BucketPrefixFixture: - return get_test_bucket_prefix(f"{request.node.name}/checkpoint_directory") + return BucketPrefixFixture(f"{request.node.name}/checkpoint_directory") @pytest.fixture def empty_directory(request) -> BucketPrefixFixture: - return get_test_bucket_prefix(f"{request.node.name}/empty_directory") + return BucketPrefixFixture(f"{request.node.name}/empty_directory") + + +@pytest.fixture +def copy_directory(request) -> CopyBucketFixture: + return get_test_copy_bucket_fixture(f"{request.node.name}/copy_directory") diff --git a/s3torchconnectorclient/python/tst/integration/test_mountpoint_s3_integration.py b/s3torchconnectorclient/python/tst/integration/test_mountpoint_s3_integration.py index cc849c43..f18fb412 100644 --- a/s3torchconnectorclient/python/tst/integration/test_mountpoint_s3_integration.py +++ b/s3torchconnectorclient/python/tst/integration/test_mountpoint_s3_integration.py @@ -21,7 +21,7 @@ ListObjectStream, ) -from conftest import BucketPrefixFixture +from conftest import BucketPrefixFixture, CopyBucketFixture logging.basicConfig( format="%(levelname)s %(name)s %(asctime)-15s %(filename)s:%(lineno)d %(message)s" @@ -404,6 +404,91 @@ def test_delete_object_invalid_bucket( ) +def test_copy_object(copy_directory: CopyBucketFixture): + full_src_key, full_dst_key = ( + copy_directory.full_src_key, + copy_directory.full_dst_key, + ) + bucket = copy_directory.bucket + + client = MountpointS3Client(copy_directory.region, TEST_USER_AGENT_PREFIX) + + client.copy_object( + src_bucket=bucket, src_key=full_src_key, dst_bucket=bucket, dst_key=full_dst_key + ) + + src_object = client.get_object(bucket, full_src_key) + dst_object = client.get_object(bucket, full_dst_key) + + assert dst_object.key == full_dst_key + assert b"".join(dst_object) == b"".join(src_object) + + +def test_copy_object_raises_when_source_bucket_does_not_exist( + copy_directory: CopyBucketFixture, +): + full_src_key, full_dst_key = ( + copy_directory.full_src_key, + copy_directory.full_dst_key, + ) + + client = MountpointS3Client(copy_directory.region, TEST_USER_AGENT_PREFIX) + # TODO: error message looks unexpected for Express One Zone, compared to the other tests for non-existing bucket or + # key (see below) + error_message = ( + "Client error: Forbidden: " + if copy_directory.storage_class == "EXPRESS_ONEZONE" + else "Service error: The object was not found" + ) + + with pytest.raises(S3Exception, match=error_message): + client.copy_object( + src_bucket=str(uuid.uuid4()), + src_key=full_src_key, + dst_bucket=copy_directory.bucket, + dst_key=full_dst_key, + ) + + +def test_copy_object_raises_when_destination_bucket_does_not_exist( + copy_directory: CopyBucketFixture, +): + full_src_key, full_dst_key = ( + copy_directory.full_src_key, + copy_directory.full_dst_key, + ) + + client = MountpointS3Client(copy_directory.region, TEST_USER_AGENT_PREFIX) + + # NOTE: `copy_object` and its underlying implementation does not + # differentiate between `NoSuchBucket` and `NoSuchKey` errors. + with pytest.raises(S3Exception, match="Service error: The object was not found"): + client.copy_object( + src_bucket=copy_directory.bucket, + src_key=full_src_key, + dst_bucket=str(uuid.uuid4()), + dst_key=full_dst_key, + ) + + +def test_copy_object_raises_when_source_key_does_not_exist( + copy_directory: CopyBucketFixture, +): + full_dst_key = copy_directory.full_dst_key + + bucket = copy_directory.bucket + + client = MountpointS3Client(copy_directory.region, TEST_USER_AGENT_PREFIX) + + with pytest.raises(S3Exception, match="Service error: The object was not found"): + client.copy_object( + src_bucket=bucket, + src_key=str(uuid.uuid4()), + dst_bucket=bucket, + dst_key=full_dst_key, + ) + + def _parse_list_result(stream: ListObjectStream, max_keys: int): object_infos = [] i = 0 diff --git a/s3torchconnectorclient/python/tst/unit/test_mountpoint_s3_client.py b/s3torchconnectorclient/python/tst/unit/test_mountpoint_s3_client.py index 3f018efd..c83d873c 100644 --- a/s3torchconnectorclient/python/tst/unit/test_mountpoint_s3_client.py +++ b/s3torchconnectorclient/python/tst/unit/test_mountpoint_s3_client.py @@ -3,10 +3,9 @@ import logging import pickle -import pytest from typing import Set, Optional -from s3torchconnectorclient import __version__ +import pytest from s3torchconnectorclient._mountpoint_s3_client import ( S3Exception, GetObjectStream, @@ -16,6 +15,8 @@ MountpointS3Client, ) +from s3torchconnectorclient import __version__ + logging.basicConfig( format="%(levelname)s %(name)s %(asctime)-15s %(filename)s:%(lineno)d %(message)s" ) @@ -374,7 +375,7 @@ def test_delete_object(force_path_style: bool): @pytest.mark.parametrize("force_path_style", [False, True]) -def test_delete_object_already_deleted(force_path_style: bool): +def test_delete_object_noop_when_key_does_not_exist(force_path_style: bool): mock_client = MockMountpointS3Client( REGION, MOCK_BUCKET, force_path_style=force_path_style ) @@ -384,14 +385,79 @@ def test_delete_object_already_deleted(force_path_style: bool): @pytest.mark.parametrize("force_path_style", [False, True]) -def test_delete_object_non_existent_bucket(force_path_style: bool): - mock_client = MockMountpointS3Client(REGION, MOCK_BUCKET, force_path_style) +def test_delete_object_raises_when_bucket_does_not_exist(force_path_style: bool): + mock_client = MockMountpointS3Client( + REGION, MOCK_BUCKET, force_path_style=force_path_style + ) client = mock_client.create_mocked_client() with pytest.raises(S3Exception, match="Service error: The bucket does not exist"): client.delete_object("bucket2", "hello_world.txt") +# NOTE[Oct. 2024]: `force_path_style` is an unsupported option. +# (https://github.com/awslabs/aws-c-s3/blob/main/include/aws/s3/s3_client.h#L74-L86). +def test_copy_object(): + src_key, src_data = ("src.txt", b"Hello, World!\n") + dst_key = "dst.txt" + + mock_client = MockMountpointS3Client(REGION, MOCK_BUCKET) + mock_client.add_object(src_key, src_data) + + client = mock_client.create_mocked_client() + client.copy_object(MOCK_BUCKET, src_key, MOCK_BUCKET, dst_key) + dst_stream = client.get_object(MOCK_BUCKET, dst_key) + + assert dst_stream.key == dst_key + assert b"".join(dst_stream) == src_data + + +# NOTE[Oct. 2024]: `force_path_style` is an unsupported option. +# (https://github.com/awslabs/aws-c-s3/blob/main/include/aws/s3/s3_client.h#L74-L86). +@pytest.mark.skip( + reason="MockMountpointS3Client does not support cross-bucket `CopyObject`" +) +def test_copy_object_raises_when_source_bucket_does_not_exist(): + src_key, src_data = ("src.txt", b"Hello, World!\n") + dst_key = "dst.txt" + + mock_client = MockMountpointS3Client(REGION, MOCK_BUCKET) + mock_client.add_object(src_key, src_data) + + client = mock_client.create_mocked_client() + with pytest.raises(S3Exception, match="Service error: The object was not found"): + client.copy_object("foobar", src_key, MOCK_BUCKET, dst_key) + + +# NOTE[Oct. 2024]: `force_path_style` is an unsupported option. +# (https://github.com/awslabs/aws-c-s3/blob/main/include/aws/s3/s3_client.h#L74-L86). +@pytest.mark.skip( + reason="MockMountpointS3Client does not support cross-bucket `CopyObject`" +) +def test_copy_object_raises_when_destination_bucket_does_not_exist(): + src_key, src_data = ("src.txt", b"Hello, World!\n") + dst_key = "dst.txt" + + mock_client = MockMountpointS3Client(REGION, MOCK_BUCKET) + mock_client.add_object(src_key, src_data) + + client = mock_client.create_mocked_client() + with pytest.raises(S3Exception, match="Service error: The object was not found"): + client.copy_object(MOCK_BUCKET, src_key, "foobar", dst_key) + + +# NOTE[October 2024]: `force_path_style` is an unsupported option. +# (https://github.com/awslabs/aws-c-s3/blob/main/include/aws/s3/s3_client.h#L74-L86). +def test_copy_object_raises_when_source_key_does_not_exist(): + dst_key = "dst.txt" + + mock_client = MockMountpointS3Client(REGION, MOCK_BUCKET) + + client = mock_client.create_mocked_client() + with pytest.raises(S3Exception, match="Service error: The object was not found"): + client.copy_object(MOCK_BUCKET, "foobar", MOCK_BUCKET, dst_key) + + def _assert_isinstance(obj, expected: type): assert isinstance(obj, expected), f"Expected a {expected}, got {type(obj)=}" diff --git a/s3torchconnectorclient/rust/src/mountpoint_s3_client.rs b/s3torchconnectorclient/rust/src/mountpoint_s3_client.rs index f13d0a7e..e36a3dc7 100644 --- a/s3torchconnectorclient/rust/src/mountpoint_s3_client.rs +++ b/s3torchconnectorclient/rust/src/mountpoint_s3_client.rs @@ -5,12 +5,12 @@ use std::sync::Arc; -use mountpoint_s3_crt::common::uri::Uri; -use mountpoint_s3_crt::common::allocator::Allocator; use mountpoint_s3_client::config::{AddressingStyle, EndpointConfig, S3ClientAuthConfig, S3ClientConfig}; use mountpoint_s3_client::types::PutObjectParams; use mountpoint_s3_client::user_agent::UserAgent; use mountpoint_s3_client::{ObjectClient, S3CrtClient}; +use mountpoint_s3_crt::common::allocator::Allocator; +use mountpoint_s3_crt::common::uri::Uri; use nix::unistd::Pid; use pyo3::types::PyTuple; use pyo3::{pyclass, pymethods, PyRef, PyResult, ToPyObject}; @@ -154,6 +154,10 @@ impl MountpointS3Client { slf.client.delete_object(slf.py(), bucket, key) } + pub fn copy_object(slf: PyRef<'_, Self>, src_bucket: String, src_key: String, dst_bucket: String, dst_key: String) -> PyResult<()> { + slf.client.copy_object(slf.py(), src_bucket, src_key, dst_bucket, dst_key) + } + pub fn __getnewargs__(slf: PyRef<'_, Self>) -> PyResult<&PyTuple> { let py = slf.py(); let state = [ diff --git a/s3torchconnectorclient/rust/src/mountpoint_s3_client_inner.rs b/s3torchconnectorclient/rust/src/mountpoint_s3_client_inner.rs index b61ca14f..5cd2094a 100644 --- a/s3torchconnectorclient/rust/src/mountpoint_s3_client_inner.rs +++ b/s3torchconnectorclient/rust/src/mountpoint_s3_client_inner.rs @@ -7,7 +7,7 @@ use std::sync::Arc; use futures::executor::block_on; use futures::TryStreamExt; -use mountpoint_s3_client::types::{ListObjectsResult, PutObjectParams}; +use mountpoint_s3_client::types::{CopyObjectParams, ListObjectsResult, PutObjectParams}; use mountpoint_s3_client::ObjectClient; use pyo3::{PyResult, Python}; @@ -42,6 +42,7 @@ pub(crate) trait MountpointS3ClientInner { ) -> PyResult; fn head_object(&self, py: Python, bucket: String, key: String) -> PyResult; fn delete_object(&self, py: Python, bucket: String, key: String) -> PyResult<()>; + fn copy_object(&self, py: Python, source_bucket: String, source_key: String, destination_bucket: String, destination_key: String) -> PyResult<()>; } pub(crate) struct MountpointS3ClientInnerImpl { @@ -117,4 +118,13 @@ where py.allow_threads(|| block_on(request).map_err(python_exception))?; Ok(()) } + + fn copy_object(&self, py: Python, source_bucket: String, source_key: String, destination_bucket: String, destination_key: String) -> PyResult<()> { + // [Oct. 2024] `CopyObjectParams` is omitted from the function signature, as it anyway contains no fields. + let params = CopyObjectParams::new(); + let request = self.client.copy_object(&source_bucket, &source_key, &destination_bucket, &destination_key, ¶ms); + + py.allow_threads(|| block_on(request).map_err(python_exception))?; + Ok(()) + } }