From 5ac39d09fb2487aa95daa1717db56d11beb1031f Mon Sep 17 00:00:00 2001 From: vincenttran-msft <101599632+vincenttran-msft@users.noreply.github.com> Date: Wed, 12 Aug 2026 18:10:34 -0700 Subject: [PATCH 1/3] Fix copy field mapping --- sdk/storage/azure-storage-blob/CHANGELOG.md | 3 + .../azure/storage/blob/_list_blobs_helper.py | 2 +- .../azure-storage-blob/tests/test_arrow.py | 69 ++++++++++++++++++- 3 files changed, 71 insertions(+), 3 deletions(-) diff --git a/sdk/storage/azure-storage-blob/CHANGELOG.md b/sdk/storage/azure-storage-blob/CHANGELOG.md index 0d1db1dbc28e..7de6a2096689 100644 --- a/sdk/storage/azure-storage-blob/CHANGELOG.md +++ b/sdk/storage/azure-storage-blob/CHANGELOG.md @@ -5,6 +5,9 @@ ### Features Added - Added `list` support to `BlobSasPermissions` for use with directory-scoped SAS tokens. +### Bugs Fixed +- Fixed an issue where `destination_snapshot` on a blob's copy properties was always `None` when listing blobs with `response_format="arrow"`. + ## 12.31.0b1 (2026-08-10) ### Features Added diff --git a/sdk/storage/azure-storage-blob/azure/storage/blob/_list_blobs_helper.py b/sdk/storage/azure-storage-blob/azure/storage/blob/_list_blobs_helper.py index 6acb2dbc8bee..f3ebfd7513fe 100644 --- a/sdk/storage/azure-storage-blob/azure/storage/blob/_list_blobs_helper.py +++ b/sdk/storage/azure-storage-blob/azure/storage/blob/_list_blobs_helper.py @@ -108,7 +108,7 @@ def _parse_arrow_response( # pylint: disable=too-many-locals,too-many-statement "CopyCompletionTime": "x-ms-copy-completion-time", "CopyStatusDescription": "x-ms-copy-status-description", "IncrementalCopy": "x-ms-incremental-copy", - "DestinationSnapshot": "x-ms-copy-destination-snapshot", + "CopyDestinationSnapshot": "x-ms-copy-destination-snapshot", } next_marker: Optional[str] = None diff --git a/sdk/storage/azure-storage-blob/tests/test_arrow.py b/sdk/storage/azure-storage-blob/tests/test_arrow.py index e38d4c5c7617..f47033a4b60e 100644 --- a/sdk/storage/azure-storage-blob/tests/test_arrow.py +++ b/sdk/storage/azure-storage-blob/tests/test_arrow.py @@ -3,7 +3,7 @@ # Licensed under the MIT License. See License.txt in the project root for # license information. # -------------------------------------------------------------------------- -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone from types import SimpleNamespace from unittest.mock import patch @@ -11,7 +11,17 @@ from azure.core.credentials import AzureNamedKeyCredential from azure.core.exceptions import ResourceExistsError, ResourceNotFoundError -from azure.storage.blob import BlobPrefix, BlobProperties, BlobServiceClient, BlobType, ContainerClient +from azure.storage.blob import ( + BlobClient, + BlobPrefix, + BlobProperties, + BlobSasPermissions, + BlobServiceClient, + BlobType, + ContainerClient, + generate_blob_sas, +) +from azure.storage.blob._list_blobs_helper import _parse_arrow_response from devtools_testutils import recorded_by_proxy from devtools_testutils.storage import StorageRecordedTestCase @@ -190,6 +200,17 @@ def _assert_blob_is_soft_deleted(self, blob: BlobProperties): assert blob.deleted_time is not None assert blob.remaining_retention_days is not None + def _wait_for_async_copy(self, blob_client) -> BlobProperties: + count = 0 + props = blob_client.get_blob_properties() + while props.copy.status == "pending": + count += 1 + if count > 15: + pytest.fail("Timed out waiting for async copy to complete.") + self.sleep(6) + props = blob_client.get_blob_properties() + return props + @BlobPreparer() @recorded_by_proxy def test_arrow_list_no_blobs(self, **kwargs): @@ -523,6 +544,50 @@ def fake_list_blob_flat_segment_apache_arrow(**kwargs): # The first blob has every property populated so we can assert the XML was fully deserialized. self.verify_all_fields(first_page[0]) + @pytest.mark.live_test_only + @BlobPreparer() + def test_arrow_list_blobs_populates_copy_destination_snapshot(self, **kwargs): + storage_account_name = kwargs.pop("storage_account_name") + + token_credential = self.get_credential(BlobServiceClient) + self.bsc = BlobServiceClient(self.account_url(storage_account_name, "blob"), credential=token_credential) + container = self.bsc.create_container(self.get_resource_name("utcontainerarrow")) + + try: + source_blob = container.get_blob_client("source_page_blob") + source_blob.create_page_blob(size=1024) + source_blob.upload_page(b"a" * 512, offset=0, length=512) + source_snapshot = source_blob.create_snapshot() + + expiry = datetime.utcnow() + timedelta(hours=1) + user_delegation_key = self.bsc.get_user_delegation_key(datetime.utcnow(), expiry) + snapshot_blob = BlobClient.from_blob_url(source_blob.url, credential=token_credential, snapshot=source_snapshot) + sas_token = self.generate_sas( + generate_blob_sas, + snapshot_blob.account_name, + snapshot_blob.container_name, + snapshot_blob.blob_name, + snapshot=snapshot_blob.snapshot, + permission=BlobSasPermissions(read=True), + expiry=expiry, + user_delegation_key=user_delegation_key, + ) + source_sas_url = BlobClient.from_blob_url(snapshot_blob.url, credential=sas_token).url + + dest_blob = container.get_blob_client("dest_incremental_copy") + dest_blob.start_copy_from_url(source_sas_url, incremental_copy=True) + expected = self._wait_for_async_copy(dest_blob).copy.destination_snapshot + assert expected is not None # the copy must have produced a destination snapshot + + dest = next( + blob + for blob in container.list_blobs(response_format="arrow", include=["copy"]) + if blob.name == "dest_incremental_copy" + ) + assert dest.copy.destination_snapshot == expected + finally: + container.delete_container() + @BlobPreparer() @recorded_by_proxy def test_arrow_walk_no_blobs(self, **kwargs): From 92f2d40dfccbbacf7c5832edbfbf3186385bf3fb Mon Sep 17 00:00:00 2001 From: vincenttran-msft <101599632+vincenttran-msft@users.noreply.github.com> Date: Wed, 12 Aug 2026 18:19:37 -0700 Subject: [PATCH 2/3] remove import _parse_arrow_response Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- sdk/storage/azure-storage-blob/tests/test_arrow.py | 1 - 1 file changed, 1 deletion(-) diff --git a/sdk/storage/azure-storage-blob/tests/test_arrow.py b/sdk/storage/azure-storage-blob/tests/test_arrow.py index f47033a4b60e..c4f725a4ea6d 100644 --- a/sdk/storage/azure-storage-blob/tests/test_arrow.py +++ b/sdk/storage/azure-storage-blob/tests/test_arrow.py @@ -21,7 +21,6 @@ ContainerClient, generate_blob_sas, ) -from azure.storage.blob._list_blobs_helper import _parse_arrow_response from devtools_testutils import recorded_by_proxy from devtools_testutils.storage import StorageRecordedTestCase From c5aab0b9af51365ea46b912349800e2f33338917 Mon Sep 17 00:00:00 2001 From: vincenttran-msft <101599632+vincenttran-msft@users.noreply.github.com> Date: Fri, 14 Aug 2026 14:01:12 -0700 Subject: [PATCH 3/3] ad-hoc tested, deleted tests, black clean --- .../azure-storage-blob/tests/test_arrow.py | 68 +------------------ 1 file changed, 2 insertions(+), 66 deletions(-) diff --git a/sdk/storage/azure-storage-blob/tests/test_arrow.py b/sdk/storage/azure-storage-blob/tests/test_arrow.py index c4f725a4ea6d..e38d4c5c7617 100644 --- a/sdk/storage/azure-storage-blob/tests/test_arrow.py +++ b/sdk/storage/azure-storage-blob/tests/test_arrow.py @@ -3,7 +3,7 @@ # Licensed under the MIT License. See License.txt in the project root for # license information. # -------------------------------------------------------------------------- -from datetime import datetime, timedelta, timezone +from datetime import datetime, timezone from types import SimpleNamespace from unittest.mock import patch @@ -11,16 +11,7 @@ from azure.core.credentials import AzureNamedKeyCredential from azure.core.exceptions import ResourceExistsError, ResourceNotFoundError -from azure.storage.blob import ( - BlobClient, - BlobPrefix, - BlobProperties, - BlobSasPermissions, - BlobServiceClient, - BlobType, - ContainerClient, - generate_blob_sas, -) +from azure.storage.blob import BlobPrefix, BlobProperties, BlobServiceClient, BlobType, ContainerClient from devtools_testutils import recorded_by_proxy from devtools_testutils.storage import StorageRecordedTestCase @@ -199,17 +190,6 @@ def _assert_blob_is_soft_deleted(self, blob: BlobProperties): assert blob.deleted_time is not None assert blob.remaining_retention_days is not None - def _wait_for_async_copy(self, blob_client) -> BlobProperties: - count = 0 - props = blob_client.get_blob_properties() - while props.copy.status == "pending": - count += 1 - if count > 15: - pytest.fail("Timed out waiting for async copy to complete.") - self.sleep(6) - props = blob_client.get_blob_properties() - return props - @BlobPreparer() @recorded_by_proxy def test_arrow_list_no_blobs(self, **kwargs): @@ -543,50 +523,6 @@ def fake_list_blob_flat_segment_apache_arrow(**kwargs): # The first blob has every property populated so we can assert the XML was fully deserialized. self.verify_all_fields(first_page[0]) - @pytest.mark.live_test_only - @BlobPreparer() - def test_arrow_list_blobs_populates_copy_destination_snapshot(self, **kwargs): - storage_account_name = kwargs.pop("storage_account_name") - - token_credential = self.get_credential(BlobServiceClient) - self.bsc = BlobServiceClient(self.account_url(storage_account_name, "blob"), credential=token_credential) - container = self.bsc.create_container(self.get_resource_name("utcontainerarrow")) - - try: - source_blob = container.get_blob_client("source_page_blob") - source_blob.create_page_blob(size=1024) - source_blob.upload_page(b"a" * 512, offset=0, length=512) - source_snapshot = source_blob.create_snapshot() - - expiry = datetime.utcnow() + timedelta(hours=1) - user_delegation_key = self.bsc.get_user_delegation_key(datetime.utcnow(), expiry) - snapshot_blob = BlobClient.from_blob_url(source_blob.url, credential=token_credential, snapshot=source_snapshot) - sas_token = self.generate_sas( - generate_blob_sas, - snapshot_blob.account_name, - snapshot_blob.container_name, - snapshot_blob.blob_name, - snapshot=snapshot_blob.snapshot, - permission=BlobSasPermissions(read=True), - expiry=expiry, - user_delegation_key=user_delegation_key, - ) - source_sas_url = BlobClient.from_blob_url(snapshot_blob.url, credential=sas_token).url - - dest_blob = container.get_blob_client("dest_incremental_copy") - dest_blob.start_copy_from_url(source_sas_url, incremental_copy=True) - expected = self._wait_for_async_copy(dest_blob).copy.destination_snapshot - assert expected is not None # the copy must have produced a destination snapshot - - dest = next( - blob - for blob in container.list_blobs(response_format="arrow", include=["copy"]) - if blob.name == "dest_incremental_copy" - ) - assert dest.copy.destination_snapshot == expected - finally: - container.delete_container() - @BlobPreparer() @recorded_by_proxy def test_arrow_walk_no_blobs(self, **kwargs):