diff --git a/sdk/storage/azure-storage-blob/CHANGELOG.md b/sdk/storage/azure-storage-blob/CHANGELOG.md index f1eee53168a8..bab7f3e87b36 100644 --- a/sdk/storage/azure-storage-blob/CHANGELOG.md +++ b/sdk/storage/azure-storage-blob/CHANGELOG.md @@ -3,6 +3,15 @@ ## 12.31.0b1 (Unreleased) ### Features Added +- Added support for service version 2026-10-06. +- Added access tier information to the response of `BlobClient`'s `download_blob` API. +The `blob_tier`, `blob_tier_inferred`, `blob_tier_change_time`, and `smart_access_tier` +properties are now populated on the downloaded blob's `properties`. +- The service-calculated CRC64 is now surfaced as `content_crc64` on the response of +`BlobClient` upload operations (`stage_block`, `stage_block_from_url`, `upload_page`, +`upload_pages_from_url`, `append_block`, `append_block_from_url`, `upload_blob`, and +`upload_blob_from_url`) in addition to `content_md5` when a content MD5 is +provided with the request. ## 12.30.0 (2026-06-08) diff --git a/sdk/storage/azure-storage-blob/azure/storage/blob/_generated/aio/operations/_blob_operations.py b/sdk/storage/azure-storage-blob/azure/storage/blob/_generated/aio/operations/_blob_operations.py index bfa47cb66d34..467256eecece 100644 --- a/sdk/storage/azure-storage-blob/azure/storage/blob/_generated/aio/operations/_blob_operations.py +++ b/sdk/storage/azure-storage-blob/azure/storage/blob/_generated/aio/operations/_blob_operations.py @@ -305,6 +305,16 @@ async def download( response_headers["x-ms-structured-content-length"] = self._deserialize( "int", response.headers.get("x-ms-structured-content-length") ) + response_headers["x-ms-access-tier"] = self._deserialize("str", response.headers.get("x-ms-access-tier")) + response_headers["x-ms-access-tier-inferred"] = self._deserialize( + "bool", response.headers.get("x-ms-access-tier-inferred") + ) + response_headers["x-ms-access-tier-change-time"] = self._deserialize( + "rfc-1123", response.headers.get("x-ms-access-tier-change-time") + ) + response_headers["x-ms-smart-access-tier"] = self._deserialize( + "str", response.headers.get("x-ms-smart-access-tier") + ) if response.status_code == 206: response_headers["Last-Modified"] = self._deserialize("rfc-1123", response.headers.get("Last-Modified")) @@ -393,6 +403,16 @@ async def download( response_headers["x-ms-structured-content-length"] = self._deserialize( "int", response.headers.get("x-ms-structured-content-length") ) + response_headers["x-ms-access-tier"] = self._deserialize("str", response.headers.get("x-ms-access-tier")) + response_headers["x-ms-access-tier-inferred"] = self._deserialize( + "bool", response.headers.get("x-ms-access-tier-inferred") + ) + response_headers["x-ms-access-tier-change-time"] = self._deserialize( + "rfc-1123", response.headers.get("x-ms-access-tier-change-time") + ) + response_headers["x-ms-smart-access-tier"] = self._deserialize( + "str", response.headers.get("x-ms-smart-access-tier") + ) deserialized = response.stream_download(self._client._pipeline, decompress=_decompress) diff --git a/sdk/storage/azure-storage-blob/azure/storage/blob/_generated/aio/operations/_block_blob_operations.py b/sdk/storage/azure-storage-blob/azure/storage/blob/_generated/aio/operations/_block_blob_operations.py index cd5bfa6bff2a..c399c616740e 100644 --- a/sdk/storage/azure-storage-blob/azure/storage/blob/_generated/aio/operations/_block_blob_operations.py +++ b/sdk/storage/azure-storage-blob/azure/storage/blob/_generated/aio/operations/_block_blob_operations.py @@ -265,6 +265,9 @@ async def upload( # pylint: disable=too-many-locals response_headers["ETag"] = self._deserialize("str", response.headers.get("ETag")) response_headers["Last-Modified"] = self._deserialize("rfc-1123", response.headers.get("Last-Modified")) response_headers["Content-MD5"] = self._deserialize("bytearray", response.headers.get("Content-MD5")) + response_headers["x-ms-content-crc64"] = self._deserialize( + "bytearray", response.headers.get("x-ms-content-crc64") + ) response_headers["x-ms-client-request-id"] = self._deserialize( "str", response.headers.get("x-ms-client-request-id") ) @@ -522,6 +525,9 @@ async def put_blob_from_url( # pylint: disable=too-many-locals response_headers["ETag"] = self._deserialize("str", response.headers.get("ETag")) response_headers["Last-Modified"] = self._deserialize("rfc-1123", response.headers.get("Last-Modified")) response_headers["Content-MD5"] = self._deserialize("bytearray", response.headers.get("Content-MD5")) + response_headers["x-ms-content-crc64"] = self._deserialize( + "bytearray", response.headers.get("x-ms-content-crc64") + ) response_headers["x-ms-client-request-id"] = self._deserialize( "str", response.headers.get("x-ms-client-request-id") ) diff --git a/sdk/storage/azure-storage-blob/azure/storage/blob/_generated/operations/_blob_operations.py b/sdk/storage/azure-storage-blob/azure/storage/blob/_generated/operations/_blob_operations.py index b2db8100471a..320e58fd666a 100644 --- a/sdk/storage/azure-storage-blob/azure/storage/blob/_generated/operations/_blob_operations.py +++ b/sdk/storage/azure-storage-blob/azure/storage/blob/_generated/operations/_blob_operations.py @@ -1775,6 +1775,16 @@ def download( response_headers["x-ms-structured-content-length"] = self._deserialize( "int", response.headers.get("x-ms-structured-content-length") ) + response_headers["x-ms-access-tier"] = self._deserialize("str", response.headers.get("x-ms-access-tier")) + response_headers["x-ms-access-tier-inferred"] = self._deserialize( + "bool", response.headers.get("x-ms-access-tier-inferred") + ) + response_headers["x-ms-access-tier-change-time"] = self._deserialize( + "rfc-1123", response.headers.get("x-ms-access-tier-change-time") + ) + response_headers["x-ms-smart-access-tier"] = self._deserialize( + "str", response.headers.get("x-ms-smart-access-tier") + ) if response.status_code == 206: response_headers["Last-Modified"] = self._deserialize("rfc-1123", response.headers.get("Last-Modified")) @@ -1863,6 +1873,16 @@ def download( response_headers["x-ms-structured-content-length"] = self._deserialize( "int", response.headers.get("x-ms-structured-content-length") ) + response_headers["x-ms-access-tier"] = self._deserialize("str", response.headers.get("x-ms-access-tier")) + response_headers["x-ms-access-tier-inferred"] = self._deserialize( + "bool", response.headers.get("x-ms-access-tier-inferred") + ) + response_headers["x-ms-access-tier-change-time"] = self._deserialize( + "rfc-1123", response.headers.get("x-ms-access-tier-change-time") + ) + response_headers["x-ms-smart-access-tier"] = self._deserialize( + "str", response.headers.get("x-ms-smart-access-tier") + ) deserialized = response.stream_download(self._client._pipeline, decompress=_decompress) diff --git a/sdk/storage/azure-storage-blob/azure/storage/blob/_generated/operations/_block_blob_operations.py b/sdk/storage/azure-storage-blob/azure/storage/blob/_generated/operations/_block_blob_operations.py index a80c4bb9852b..c0bdfcbf0438 100644 --- a/sdk/storage/azure-storage-blob/azure/storage/blob/_generated/operations/_block_blob_operations.py +++ b/sdk/storage/azure-storage-blob/azure/storage/blob/_generated/operations/_block_blob_operations.py @@ -924,6 +924,9 @@ def upload( # pylint: disable=inconsistent-return-statements,too-many-locals response_headers["ETag"] = self._deserialize("str", response.headers.get("ETag")) response_headers["Last-Modified"] = self._deserialize("rfc-1123", response.headers.get("Last-Modified")) response_headers["Content-MD5"] = self._deserialize("bytearray", response.headers.get("Content-MD5")) + response_headers["x-ms-content-crc64"] = self._deserialize( + "bytearray", response.headers.get("x-ms-content-crc64") + ) response_headers["x-ms-client-request-id"] = self._deserialize( "str", response.headers.get("x-ms-client-request-id") ) @@ -1181,6 +1184,9 @@ def put_blob_from_url( # pylint: disable=inconsistent-return-statements,too-man response_headers["ETag"] = self._deserialize("str", response.headers.get("ETag")) response_headers["Last-Modified"] = self._deserialize("rfc-1123", response.headers.get("Last-Modified")) response_headers["Content-MD5"] = self._deserialize("bytearray", response.headers.get("Content-MD5")) + response_headers["x-ms-content-crc64"] = self._deserialize( + "bytearray", response.headers.get("x-ms-content-crc64") + ) response_headers["x-ms-client-request-id"] = self._deserialize( "str", response.headers.get("x-ms-client-request-id") ) diff --git a/sdk/storage/azure-storage-blob/azure/storage/blob/_serialize.py b/sdk/storage/azure-storage-blob/azure/storage/blob/_serialize.py index a18129ab5c8c..8538f9344212 100644 --- a/sdk/storage/azure-storage-blob/azure/storage/blob/_serialize.py +++ b/sdk/storage/azure-storage-blob/azure/storage/blob/_serialize.py @@ -65,6 +65,7 @@ "2026-02-06", "2026-04-06", "2026-06-06", + "2026-10-06", ] diff --git a/sdk/storage/azure-storage-blob/tests/test_append_blob.py b/sdk/storage/azure-storage-blob/tests/test_append_blob.py index b1f038bcb378..38e47c4fea4d 100644 --- a/sdk/storage/azure-storage-blob/tests/test_append_blob.py +++ b/sdk/storage/azure-storage-blob/tests/test_append_blob.py @@ -279,6 +279,8 @@ def test_append_block_with_md5(self, **kwargs): assert resp["blob_committed_block_count"] == 1 assert resp["etag"] is not None assert resp["last_modified"] is not None + assert resp["content_md5"] is not None + assert resp["content_crc64"] is not None # Assert @@ -410,6 +412,8 @@ def test_append_block_from_url_and_validate_content_md5(self, **kwargs): assert resp.get("blob_committed_block_count") == 1 assert resp.get("etag") is not None assert resp.get("last_modified") is not None + assert resp.get("content_md5") is not None + assert resp.get("content_crc64") is not None # Assert the destination blob is constructed correctly destination_blob_properties = destination_blob_client.get_blob_properties() diff --git a/sdk/storage/azure-storage-blob/tests/test_append_blob_async.py b/sdk/storage/azure-storage-blob/tests/test_append_blob_async.py index e708472b62a1..6ab209e5982b 100644 --- a/sdk/storage/azure-storage-blob/tests/test_append_blob_async.py +++ b/sdk/storage/azure-storage-blob/tests/test_append_blob_async.py @@ -301,6 +301,8 @@ async def test_append_block_with_md5(self, **kwargs): assert resp["blob_committed_block_count"] == 1 assert resp["etag"] is not None assert resp["last_modified"] is not None + assert resp["content_md5"] is not None + assert resp["content_crc64"] is not None # Assert @@ -409,6 +411,8 @@ async def test_append_block_from_url_and_validate_content_md5(self, **kwargs): assert resp.get("blob_committed_block_count") == 1 assert resp.get("etag") is not None assert resp.get("last_modified") is not None + assert resp.get("content_md5") is not None + assert resp.get("content_crc64") is not None # Assert the destination blob is constructed correctly destination_blob_properties = await destination_blob_client.get_blob_properties() diff --git a/sdk/storage/azure-storage-blob/tests/test_block_blob.py b/sdk/storage/azure-storage-blob/tests/test_block_blob.py index eff4b8f2bab3..6a4069af0a85 100644 --- a/sdk/storage/azure-storage-blob/tests/test_block_blob.py +++ b/sdk/storage/azure-storage-blob/tests/test_block_blob.py @@ -585,9 +585,11 @@ def test_upload_blob_from_url_with_source_content_md5(self, **kwargs): new_blob = self.bsc.get_blob_client(self.container_name, blob_name) # Assert - new_blob.upload_blob_from_url( + resp = new_blob.upload_blob_from_url( source_blob_url, include_source_blob_properties=True, source_content_md5=source_md5 ) + assert resp.get("content_md5") is not None + assert resp.get("content_crc64") is not None with pytest.raises(HttpResponseError): new_blob.upload_blob_from_url( source_blob_url, include_source_blob_properties=False, source_content_md5=bad_source_md5 @@ -718,9 +720,11 @@ def test_put_block_with_md5(self, **kwargs): blob = self._create_blob() # Act - blob.stage_block(1, b"block", validate_content=True) + resp = blob.stage_block(1, b"block", validate_content=True) # Assert + assert resp.get("content_md5") is not None + assert resp.get("content_crc64") is not None @BlobPreparer() @recorded_by_proxy diff --git a/sdk/storage/azure-storage-blob/tests/test_block_blob_async.py b/sdk/storage/azure-storage-blob/tests/test_block_blob_async.py index 05ea750b255f..b5dbc367c01e 100644 --- a/sdk/storage/azure-storage-blob/tests/test_block_blob_async.py +++ b/sdk/storage/azure-storage-blob/tests/test_block_blob_async.py @@ -618,9 +618,11 @@ async def test_upload_blob_from_url_with_source_content_md5(self, **kwargs): new_blob = self.bsc.get_blob_client(self.container_name, blob_name) # Assert - await new_blob.upload_blob_from_url( + resp = await new_blob.upload_blob_from_url( source_blob_url, include_source_blob_properties=True, source_content_md5=source_md5 ) + assert resp.get("content_md5") is not None + assert resp.get("content_crc64") is not None with pytest.raises(HttpResponseError): await new_blob.upload_blob_from_url( source_blob_url, include_source_blob_properties=False, source_content_md5=bad_source_md5 @@ -803,9 +805,11 @@ async def test_put_block_with_md5(self, **kwargs): blob = await self._create_blob() # Act - await blob.stage_block(1, b"block", validate_content=True) + resp = await blob.stage_block(1, b"block", validate_content=True) # Assert + assert resp.get("content_md5") is not None + assert resp.get("content_crc64") is not None @BlobPreparer() @recorded_by_proxy_async diff --git a/sdk/storage/azure-storage-blob/tests/test_block_blob_sync_copy.py b/sdk/storage/azure-storage-blob/tests/test_block_blob_sync_copy.py index 6333bae1888b..3bfff4ac6a4f 100644 --- a/sdk/storage/azure-storage-blob/tests/test_block_blob_sync_copy.py +++ b/sdk/storage/azure-storage-blob/tests/test_block_blob_sync_copy.py @@ -205,13 +205,15 @@ def test_put_block_from_url_and_validate_content_md5(self, **kwargs): src_md5 = calculate_content_md5(self.source_blob_data) # Act part 1: put block from url with md5 validation - dest_blob.stage_block_from_url( + resp = dest_blob.stage_block_from_url( block_id=1, source_url=self.source_blob_url, source_content_md5=src_md5, source_offset=0, source_length=8 * 1024, ) + assert resp.get("content_md5") is not None + assert resp.get("content_crc64") is not None # Assert block was staged committed, uncommitted = dest_blob.get_block_list("all") diff --git a/sdk/storage/azure-storage-blob/tests/test_block_blob_sync_copy_async.py b/sdk/storage/azure-storage-blob/tests/test_block_blob_sync_copy_async.py index 9c87c0885637..f1c930625121 100644 --- a/sdk/storage/azure-storage-blob/tests/test_block_blob_sync_copy_async.py +++ b/sdk/storage/azure-storage-blob/tests/test_block_blob_sync_copy_async.py @@ -167,13 +167,15 @@ async def test_put_block_from_url_and_vldte_content_md5(self, **kwargs): src_md5 = calculate_content_md5(self.source_blob_data) # Act part 1: put block from url with md5 validation - await dest_blob.stage_block_from_url( + resp = await dest_blob.stage_block_from_url( block_id=1, source_url=self.source_blob_url, source_content_md5=src_md5, source_offset=0, source_length=8 * 1024, ) + assert resp.get("content_md5") is not None + assert resp.get("content_crc64") is not None # Assert block was staged committed, uncommitted = await dest_blob.get_block_list("all") diff --git a/sdk/storage/azure-storage-blob/tests/test_common_blob.py b/sdk/storage/azure-storage-blob/tests/test_common_blob.py index ec052556a066..932827f24c8c 100644 --- a/sdk/storage/azure-storage-blob/tests/test_common_blob.py +++ b/sdk/storage/azure-storage-blob/tests/test_common_blob.py @@ -970,7 +970,7 @@ def test_set_blob_metadata_with_upper_case(self, **kwargs): assert md["hello"] == "world" assert md["number"] == "42" assert md["UP"] == "UPval" - assert not "up" in md + assert "up" not in md @BlobPreparer() @recorded_by_proxy @@ -995,7 +995,7 @@ def test_set_blob_metadata_with_if_tags(self, **kwargs): assert md["hello"] == "world" assert md["number"] == "42" assert md["UP"] == "UPval" - assert not "up" in md + assert "up" not in md @BlobPreparer() @recorded_by_proxy @@ -1018,7 +1018,7 @@ def test_set_blob_metadata_returns_vid(self, **kwargs): assert md["hello"] == "world" assert md["number"] == "42" assert md["UP"] == "UPval" - assert not "up" in md + assert "up" not in md @BlobPreparer() @recorded_by_proxy @@ -4000,4 +4000,24 @@ def test_blob_fns_directory_fail(self, **kwargs): return variables + @BlobPreparer() + @recorded_by_proxy + def test_download_blob_returns_access_tier(self, **kwargs): + storage_account_name = kwargs.pop("storage_account_name") + storage_account_key = kwargs.pop("storage_account_key") + + self._setup(storage_account_name, storage_account_key) + blob = self.bsc.get_container_client(self.container_name).get_blob_client(self._get_blob_reference()) + blob.upload_blob(b"hello world", overwrite=True) + blob.set_standard_blob_tier(StandardBlobTier.SMART) + + # Act + downloader = blob.download_blob() + + # Assert + assert StandardBlobTier.SMART == downloader.properties.blob_tier + assert downloader.properties.smart_access_tier is not None + assert downloader.properties.blob_tier_change_time is not None + assert not downloader.properties.blob_tier_inferred + # ------------------------------------------------------------------------------ diff --git a/sdk/storage/azure-storage-blob/tests/test_common_blob_async.py b/sdk/storage/azure-storage-blob/tests/test_common_blob_async.py index edb640ba15dc..88885ee7d1ff 100644 --- a/sdk/storage/azure-storage-blob/tests/test_common_blob_async.py +++ b/sdk/storage/azure-storage-blob/tests/test_common_blob_async.py @@ -1210,7 +1210,7 @@ async def test_set_blob_metadata_with_upper_case(self, **kwargs): assert md["hello"] == "world" assert md["number"] == "42" assert md["UP"] == "UPval" - assert not "up" in md + assert "up" not in md @BlobPreparer() @recorded_by_proxy_async @@ -1238,7 +1238,7 @@ async def test_set_blob_metadata_with_if_tags(self, **kwargs): assert md["hello"] == "world" assert md["number"] == "42" assert md["UP"] == "UPval" - assert not "up" in md + assert "up" not in md @BlobPreparer() @recorded_by_proxy_async @@ -1262,7 +1262,7 @@ async def test_set_blob_metadata_returns_vid(self, **kwargs): assert md["hello"] == "world" assert md["number"] == "42" assert md["UP"] == "UPval" - assert not "up" in md + assert "up" not in md @BlobPreparer() @recorded_by_proxy_async @@ -3908,5 +3908,25 @@ async def test_blob_fns_directory_fail(self, **kwargs): return variables + @BlobPreparer() + @recorded_by_proxy_async + async def test_download_blob_returns_access_tier(self, **kwargs): + storage_account_name = kwargs.pop("storage_account_name") + storage_account_key = kwargs.pop("storage_account_key") + + await self._setup(storage_account_name, storage_account_key) + blob = self.bsc.get_container_client(self.container_name).get_blob_client(self._get_blob_reference()) + await blob.upload_blob(b"hello world") + await blob.set_standard_blob_tier(StandardBlobTier.SMART) + + # Act + downloader = await blob.download_blob() + + # Assert + assert StandardBlobTier.SMART == downloader.properties.blob_tier + assert downloader.properties.smart_access_tier is not None + assert downloader.properties.blob_tier_change_time is not None + assert not downloader.properties.blob_tier_inferred + # ------------------------------------------------------------------------------ diff --git a/sdk/storage/azure-storage-blob/tests/test_page_blob.py b/sdk/storage/azure-storage-blob/tests/test_page_blob.py index e29a68591899..d02c4d41407a 100644 --- a/sdk/storage/azure-storage-blob/tests/test_page_blob.py +++ b/sdk/storage/azure-storage-blob/tests/test_page_blob.py @@ -417,9 +417,11 @@ def test_update_page_with_md5(self, **kwargs): # Act data = self.get_random_bytes(512) - blob.upload_page(data, offset=0, length=512, validate_content=True) + resp = blob.upload_page(data, offset=0, length=512, validate_content=True) # Assert + assert resp.get("content_md5") is not None + assert resp.get("content_crc64") is not None @BlobPreparer() @recorded_by_proxy @@ -752,6 +754,8 @@ def test_upload_pages_from_url_and_validate_content_md5(self, **kwargs): ) assert resp.get("etag") is not None assert resp.get("last_modified") is not None + assert resp.get("content_md5") is not None + assert resp.get("content_crc64") is not None # Assert the destination blob is constructed correctly blob_properties = destination_blob_client.get_blob_properties() diff --git a/sdk/storage/azure-storage-blob/tests/test_page_blob_async.py b/sdk/storage/azure-storage-blob/tests/test_page_blob_async.py index 9131d2c7072e..6e7f8523dce1 100644 --- a/sdk/storage/azure-storage-blob/tests/test_page_blob_async.py +++ b/sdk/storage/azure-storage-blob/tests/test_page_blob_async.py @@ -451,8 +451,10 @@ async def test_update_page_with_md5(self, **kwargs): # Act data = self.get_random_bytes(512) - await blob.upload_page(data, offset=0, length=512, validate_content=True) + resp = await blob.upload_page(data, offset=0, length=512, validate_content=True) # Assert + assert resp.get("content_md5") is not None + assert resp.get("content_crc64") is not None @BlobPreparer() @recorded_by_proxy_async @@ -697,6 +699,8 @@ async def test_upload_pages_from_url_and_validate_content_md5(self, **kwargs): ) assert resp.get("etag") is not None assert resp.get("last_modified") is not None + assert resp.get("content_md5") is not None + assert resp.get("content_crc64") is not None # Assert the destination blob is constructed correctly blob_properties = await destination_blob_client.get_blob_properties() diff --git a/sdk/storage/azure-storage-file-share/CHANGELOG.md b/sdk/storage/azure-storage-file-share/CHANGELOG.md index 230834ee2c64..ee89756394d2 100644 --- a/sdk/storage/azure-storage-file-share/CHANGELOG.md +++ b/sdk/storage/azure-storage-file-share/CHANGELOG.md @@ -3,6 +3,14 @@ ## 12.27.0b1 (Unreleased) ### Features Added +- Added `list_ranges` and `list_ranges_diff` APIs to `ShareFileClient` which return an auto-paging iterable of +`FileRange` and support continuation tokens and `results_per_page` for enumerating file ranges across multiple +service responses. +- Added a new `FileRange` model with `start`, `end`, and `cleared` properties. + +### Other Changes +- Deprecated `ShareFileClient`'s `get_ranges` and `get_ranges_diff` APIs in favor of `list_ranges` and +`list_ranges_diff`. ## 12.26.0 (2026-06-08) diff --git a/sdk/storage/azure-storage-file-share/azure/storage/fileshare/__init__.py b/sdk/storage/azure-storage-file-share/azure/storage/fileshare/__init__.py index 4b1cf17142a8..b6d5b51f530c 100644 --- a/sdk/storage/azure-storage-file-share/azure/storage/fileshare/__init__.py +++ b/sdk/storage/azure-storage-file-share/azure/storage/fileshare/__init__.py @@ -27,6 +27,7 @@ CorsRule, DirectoryProperties, FileProperties, + FileRange, FileSasPermissions, Handle, Metrics, @@ -55,6 +56,7 @@ "DirectoryProperties", "ExponentialRetry", "FileProperties", + "FileRange", "FileSasPermissions", "generate_account_sas", "generate_file_sas", diff --git a/sdk/storage/azure-storage-file-share/azure/storage/fileshare/_file_client.py b/sdk/storage/azure-storage-file-share/azure/storage/fileshare/_file_client.py index ba589219fe33..836c87f8efa6 100644 --- a/sdk/storage/azure-storage-file-share/azure/storage/fileshare/_file_client.py +++ b/sdk/storage/azure-storage-file-share/azure/storage/fileshare/_file_client.py @@ -7,6 +7,7 @@ import functools import time +import warnings from datetime import datetime from io import BytesIO from typing import Any, AnyStr, Callable, cast, Dict, IO, Iterable, List, Literal, Optional, Tuple, Union, TYPE_CHECKING @@ -27,7 +28,7 @@ from ._generated import AzureFileStorage from ._generated.models import FileHTTPHeaders from ._lease import ShareLeaseClient -from ._models import FileProperties, Handle, HandlesPaged +from ._models import FileProperties, FileRange, FileRangePaged, Handle, HandlesPaged from ._parser import _datetime_to_str, _get_file_permission, _parse_snapshot from ._serialize import ( get_access_conditions, @@ -1488,9 +1489,12 @@ def upload_range_from_url( def get_ranges( self, offset: Optional[int] = None, length: Optional[int] = None, **kwargs: Any ) -> List[Dict[str, int]]: - """Returns the list of valid page ranges for a file or snapshot + """DEPRECATED: Returns the list of valid page ranges for a file or snapshot of a file. + .. deprecated:: + Use :func:`list_ranges` instead. + :param int offset: Specifies the start offset of bytes over which to get ranges. :param int length: @@ -1512,6 +1516,8 @@ def get_ranges( A list of valid ranges. :rtype: List[dict[str, int]] """ + warnings.warn("get_ranges is deprecated, use list_ranges instead", DeprecationWarning) + options = _get_ranges_options(snapshot=self.snapshot, offset=offset, length=length, **kwargs) try: ranges = self._client.file.get_range_list(**options) @@ -1519,6 +1525,37 @@ def get_ranges( process_storage_error(error) return [{"start": file_range.start, "end": file_range.end} for file_range in ranges.ranges] + @distributed_trace + def list_ranges( + self, *, offset: Optional[int] = None, length: Optional[int] = None, **kwargs: Any + ) -> ItemPaged[FileRange]: + """Returns the list of valid ranges for a file or snapshot of a file. + + :keyword int offset: + Specifies the start offset of bytes over which to get ranges. + :keyword int length: + Number of bytes to use over which to get ranges. + :keyword lease: + Required if the file has an active lease. Value can be a ShareLeaseClient object + or the lease ID as a string. + :paramtype lease: ~azure.storage.fileshare.ShareLeaseClient or str + :keyword int results_per_page: + The maximum number of file ranges to retrieve per API call. + :keyword int timeout: + Sets the server-side timeout for the operation in seconds. For more details see + https://learn.microsoft.com/rest/api/storageservices/setting-timeouts-for-file-service-operations. + This value is not tracked or validated on the client. To configure client-side network timesouts + see `here `__. + :returns: + An iterable (auto-paging) of valid ranges. + :rtype: ~azure.core.paging.ItemPaged[~azure.storage.fileshare.FileRange] + """ + results_per_page = kwargs.pop("results_per_page", None) + options = _get_ranges_options(snapshot=self.snapshot, offset=offset, length=length, **kwargs) + command = functools.partial(self._client.file.get_range_list, **options) + return ItemPaged(command, results_per_page=results_per_page, page_iterator_class=FileRangePaged) + @distributed_trace def get_ranges_diff( self, @@ -1529,11 +1566,14 @@ def get_ranges_diff( include_renames: Optional[bool] = None, **kwargs: Any, ) -> Tuple[List[Dict[str, int]], List[Dict[str, int]]]: - """Returns the list of valid page ranges for a file or snapshot + """DEPRECATED: Returns the list of valid page ranges for a file or snapshot of a file. .. versionadded:: 12.6.0 + .. deprecated:: + Use :func:`list_ranges_diff` instead. + :param int offset: Specifies the start offset of bytes over which to get ranges. :param int length: @@ -1562,6 +1602,8 @@ def get_ranges_diff( The first element are filled file ranges, the 2nd element is cleared file ranges. :rtype: tuple[list[dict[str, str], list[dict[str, str]] """ + warnings.warn("get_ranges_diff is deprecated, use list_ranges_diff instead", DeprecationWarning) + options = _get_ranges_options( snapshot=self.snapshot, offset=offset, @@ -1576,6 +1618,61 @@ def get_ranges_diff( process_storage_error(error) return get_file_ranges_result(ranges) + @distributed_trace + def list_ranges_diff( + self, + previous_sharesnapshot: Union[str, Dict[str, Any]], + *, + offset: Optional[int] = None, + length: Optional[int] = None, + include_renames: Optional[bool] = None, + **kwargs: Any, + ) -> ItemPaged[FileRange]: + """Returns the list of valid ranges for a file or snapshot of a file, listing only the + ranges that changed between the target snapshot (or live file) and a previous snapshot. + + :param str previous_sharesnapshot: + The snapshot diff parameter that contains an opaque DateTime value that + specifies a previous file snapshot to be compared + against a more recent snapshot or the current file. + :keyword int offset: + Specifies the start offset of bytes over which to get ranges. + :keyword int length: + Number of bytes to use over which to get ranges. + :keyword Optional[bool] include_renames: + Only valid if previous_sharesnapshot parameter is provided. Specifies whether the changed ranges for + a file that has been renamed or moved between the target snapshot (or live file) and the previous + snapshot should be listed. If set to True, the valid changed ranges for the file will be returned. + If set to False, the operation will result in a 409 (Conflict) response. + :keyword lease: + Required if the file has an active lease. Value can be a ShareLeaseClient object + or the lease ID as a string. + :paramtype lease: ~azure.storage.fileshare.ShareLeaseClient or str + :keyword int results_per_page: + The maximum number of file ranges to retrieve per API call. + :keyword int timeout: + Sets the server-side timeout for the operation in seconds. For more details see + https://learn.microsoft.com/rest/api/storageservices/setting-timeouts-for-file-service-operations. + This value is not tracked or validated on the client. To configure client-side network timesouts + see `here `__. + :returns: + An iterable (auto-paging) of valid ranges. Each range has a ``cleared`` attribute indicating + whether the range was cleared between the previous snapshot and the target. + :rtype: ~azure.core.paging.ItemPaged[~azure.storage.fileshare.FileRange] + """ + results_per_page = kwargs.pop("results_per_page", None) + options = _get_ranges_options( + snapshot=self.snapshot, + offset=offset, + length=length, + previous_sharesnapshot=previous_sharesnapshot, + support_rename=include_renames, + **kwargs, + ) + command = functools.partial(self._client.file.get_range_list, **options) + return ItemPaged(command, results_per_page=results_per_page, page_iterator_class=FileRangePaged) + @distributed_trace def clear_range(self, offset: int, length: int, **kwargs: Any) -> Dict[str, Any]: """Clears the specified range and releases the space used in storage for diff --git a/sdk/storage/azure-storage-file-share/azure/storage/fileshare/_file_client.pyi b/sdk/storage/azure-storage-file-share/azure/storage/fileshare/_file_client.pyi index 64deea3957e7..6dd4983ea60c 100644 --- a/sdk/storage/azure-storage-file-share/azure/storage/fileshare/_file_client.pyi +++ b/sdk/storage/azure-storage-file-share/azure/storage/fileshare/_file_client.pyi @@ -32,6 +32,7 @@ from ._lease import ShareLeaseClient from ._models import ( ContentSettings, FileProperties, + FileRange, Handle, NTFSAttributes, ) @@ -307,6 +308,17 @@ class ShareFileClient(StorageAccountHostsMixin): **kwargs: Any ) -> List[Dict[str, int]]: ... @distributed_trace + def list_ranges( + self, + *, + offset: Optional[int] = None, + length: Optional[int] = None, + lease: Optional[Union[ShareLeaseClient, str]] = None, + results_per_page: Optional[int] = None, + timeout: Optional[int] = None, + **kwargs: Any + ) -> ItemPaged[FileRange]: ... + @distributed_trace def get_ranges_diff( self, previous_sharesnapshot: Union[str, Dict[str, Any]], @@ -319,6 +331,19 @@ class ShareFileClient(StorageAccountHostsMixin): **kwargs: Any ) -> Tuple[List[Dict[str, int]], List[Dict[str, int]]]: ... @distributed_trace + def list_ranges_diff( + self, + previous_sharesnapshot: Union[str, Dict[str, Any]], + *, + offset: Optional[int] = None, + length: Optional[int] = None, + include_renames: Optional[bool] = None, + lease: Optional[Union[ShareLeaseClient, str]] = None, + results_per_page: Optional[int] = None, + timeout: Optional[int] = None, + **kwargs: Any + ) -> ItemPaged[FileRange]: ... + @distributed_trace def clear_range( self, offset: int, diff --git a/sdk/storage/azure-storage-file-share/azure/storage/fileshare/_generated/aio/operations/_file_operations.py b/sdk/storage/azure-storage-file-share/azure/storage/fileshare/_generated/aio/operations/_file_operations.py index 1f6bc63e53c0..e779d40e5662 100644 --- a/sdk/storage/azure-storage-file-share/azure/storage/fileshare/_generated/aio/operations/_file_operations.py +++ b/sdk/storage/azure-storage-file-share/azure/storage/fileshare/_generated/aio/operations/_file_operations.py @@ -1576,6 +1576,8 @@ async def get_range_list( timeout: Optional[int] = None, range: Optional[str] = None, support_rename: Optional[bool] = None, + marker: Optional[str] = None, + maxresults: Optional[int] = None, lease_access_conditions: Optional[_models.LeaseAccessConditions] = None, **kwargs: Any ) -> _models.ShareFileRangeList: @@ -1602,6 +1604,15 @@ async def get_range_list( operation will result in a failure with 409 (Conflict) response. The default value is false. Default value is None. :type support_rename: bool + :param marker: A string value that identifies the portion of the list to be returned with the + next list operation. The operation returns a marker value within the response body if the list + returned was not complete. The marker value may then be used in a subsequent call to request + the next set of list items. The marker value is opaque to the client. Default value is None. + :type marker: str + :param maxresults: Specifies the maximum number of entries to return. If the request does not + specify maxresults, or specifies a value greater than 5,000, the server will return up to 5,000 + items. Default value is None. + :type maxresults: int :param lease_access_conditions: Parameter group. Default value is None. :type lease_access_conditions: ~azure.storage.fileshare.models.LeaseAccessConditions :return: ShareFileRangeList or the result of cls(response) @@ -1635,6 +1646,8 @@ async def get_range_list( range=range, lease_id=_lease_id, support_rename=support_rename, + marker=marker, + maxresults=maxresults, allow_trailing_dot=self._config.allow_trailing_dot, file_request_intent=self._config.file_request_intent, comp=comp, diff --git a/sdk/storage/azure-storage-file-share/azure/storage/fileshare/_generated/models/_models_py3.py b/sdk/storage/azure-storage-file-share/azure/storage/fileshare/_generated/models/_models_py3.py index 14572ebc405a..c20d2d586f38 100644 --- a/sdk/storage/azure-storage-file-share/azure/storage/fileshare/_generated/models/_models_py3.py +++ b/sdk/storage/azure-storage-file-share/azure/storage/fileshare/_generated/models/_models_py3.py @@ -1113,11 +1113,14 @@ class ShareFileRangeList(_serialization.Model): :vartype ranges: list[~azure.storage.fileshare.models.FileRange] :ivar clear_ranges: :vartype clear_ranges: list[~azure.storage.fileshare.models.ClearRange] + :ivar next_marker: + :vartype next_marker: str """ _attribute_map = { "ranges": {"key": "Ranges", "type": "[FileRange]", "xml": {"itemsName": "Range"}}, "clear_ranges": {"key": "ClearRanges", "type": "[ClearRange]", "xml": {"itemsName": "ClearRange"}}, + "next_marker": {"key": "NextMarker", "type": "str"}, } def __init__( @@ -1125,6 +1128,7 @@ def __init__( *, ranges: Optional[list["_models.FileRange"]] = None, clear_ranges: Optional[list["_models.ClearRange"]] = None, + next_marker: Optional[str] = None, **kwargs: Any ) -> None: """ @@ -1132,10 +1136,13 @@ def __init__( :paramtype ranges: list[~azure.storage.fileshare.models.FileRange] :keyword clear_ranges: :paramtype clear_ranges: list[~azure.storage.fileshare.models.ClearRange] + :keyword next_marker: + :paramtype next_marker: str """ super().__init__(**kwargs) self.ranges = ranges self.clear_ranges = clear_ranges + self.next_marker = next_marker class ShareItemInternal(_serialization.Model): diff --git a/sdk/storage/azure-storage-file-share/azure/storage/fileshare/_generated/operations/_file_operations.py b/sdk/storage/azure-storage-file-share/azure/storage/fileshare/_generated/operations/_file_operations.py index 22ca6948e8b4..8fb8e8632a55 100644 --- a/sdk/storage/azure-storage-file-share/azure/storage/fileshare/_generated/operations/_file_operations.py +++ b/sdk/storage/azure-storage-file-share/azure/storage/fileshare/_generated/operations/_file_operations.py @@ -792,6 +792,8 @@ def build_get_range_list_request( range: Optional[str] = None, lease_id: Optional[str] = None, support_rename: Optional[bool] = None, + marker: Optional[str] = None, + maxresults: Optional[int] = None, allow_trailing_dot: Optional[bool] = None, file_request_intent: Optional[Union[str, _models.ShareTokenIntent]] = None, **kwargs: Any @@ -818,6 +820,10 @@ def build_get_range_list_request( _params["prevsharesnapshot"] = _SERIALIZER.query("prevsharesnapshot", prevsharesnapshot, "str") if timeout is not None: _params["timeout"] = _SERIALIZER.query("timeout", timeout, "int", minimum=0) + if marker is not None: + _params["marker"] = _SERIALIZER.query("marker", marker, "str") + if maxresults is not None: + _params["maxresults"] = _SERIALIZER.query("maxresults", maxresults, "int", minimum=1) # Construct headers _headers["x-ms-version"] = _SERIALIZER.header("version", version, "str") @@ -2836,6 +2842,8 @@ def get_range_list( timeout: Optional[int] = None, range: Optional[str] = None, support_rename: Optional[bool] = None, + marker: Optional[str] = None, + maxresults: Optional[int] = None, lease_access_conditions: Optional[_models.LeaseAccessConditions] = None, **kwargs: Any ) -> _models.ShareFileRangeList: @@ -2862,6 +2870,15 @@ def get_range_list( operation will result in a failure with 409 (Conflict) response. The default value is false. Default value is None. :type support_rename: bool + :param marker: A string value that identifies the portion of the list to be returned with the + next list operation. The operation returns a marker value within the response body if the list + returned was not complete. The marker value may then be used in a subsequent call to request + the next set of list items. The marker value is opaque to the client. Default value is None. + :type marker: str + :param maxresults: Specifies the maximum number of entries to return. If the request does not + specify maxresults, or specifies a value greater than 5,000, the server will return up to 5,000 + items. Default value is None. + :type maxresults: int :param lease_access_conditions: Parameter group. Default value is None. :type lease_access_conditions: ~azure.storage.fileshare.models.LeaseAccessConditions :return: ShareFileRangeList or the result of cls(response) @@ -2895,6 +2912,8 @@ def get_range_list( range=range, lease_id=_lease_id, support_rename=support_rename, + marker=marker, + maxresults=maxresults, allow_trailing_dot=self._config.allow_trailing_dot, file_request_intent=self._config.file_request_intent, comp=comp, diff --git a/sdk/storage/azure-storage-file-share/azure/storage/fileshare/_models.py b/sdk/storage/azure-storage-file-share/azure/storage/fileshare/_models.py index 83742b9d80d9..4b942bc1c8c9 100644 --- a/sdk/storage/azure-storage-file-share/azure/storage/fileshare/_models.py +++ b/sdk/storage/azure-storage-file-share/azure/storage/fileshare/_models.py @@ -32,7 +32,7 @@ if TYPE_CHECKING: from datetime import datetime - from ._generated.models import ShareRootSquash + from ._generated.models import ShareFileRangeList, ShareRootSquash def _wrap_item(item): @@ -558,7 +558,7 @@ class ShareProperties(DictMixin): snapshot: Optional[str] = None """Snapshot of the share.""" deleted: Optional[bool] = None - """Whether this share was deleted. + """Whether this share was deleted. This is a service returned value, and the value will be set when list shared including deleted ones.""" deleted_time: Optional["datetime"] = None """A datetime object representing the time at which the share was deleted. @@ -1117,6 +1117,96 @@ def _extract_data_cb(self, get_next_return): return self._response.next_marker or None, self.current_page +class FileRange(DictMixin): + """File Range. + + :param int start: + Start of file range in bytes. + :param int end: + End of file range in bytes. + """ + + start: int + """Start of file range in bytes.""" + end: int + """End of file range in bytes.""" + cleared: bool + """Whether the range has been cleared. Only applicable when using list_ranges_diff.""" + + def __init__(self, start: int, end: int, *, cleared: bool = False) -> None: + self.start = start + self.end = end + self.cleared = cleared + + +def parse_file_range_list(ranges: "ShareFileRangeList") -> List[FileRange]: + file_ranges = ranges.ranges or [] + clear_ranges = ranges.clear_ranges or [] + + result: List[FileRange] = [] + f_i, c_i = 0, 0 + + # Combine file ranges and clear ranges into a single list, sorted by start + while f_i < len(file_ranges) and c_i < len(clear_ranges): + f, c = file_ranges[f_i], clear_ranges[c_i] + if f.start < c.start: + result.append(FileRange(start=f.start, end=f.end, cleared=False)) + f_i += 1 + else: + result.append(FileRange(start=c.start, end=c.end, cleared=True)) + c_i += 1 + + # Grab remaining elements in either list + result += [FileRange(start=r.start, end=r.end, cleared=False) for r in file_ranges[f_i:]] + result += [FileRange(start=r.start, end=r.end, cleared=True) for r in clear_ranges[c_i:]] + + return result + + +class FileRangePaged(PageIterator): + """An iterable of File Ranges. + + :param Callable command: Function to retrieve the next page of items. + :param Optional[int] results_per_page: The maximum number of file ranges to retrieve per call. + :param Optional[str] continuation_token: An opaque continuation token to retrieve the next page of results. + """ + + results_per_page: Optional[int] = None + """The maximum number of results retrieved per API call.""" + location_mode: Optional[str] = None + """The location mode being used to list results. + The available options include "primary" and "secondary".""" + current_page: List[FileRange] + """The current page of listed results.""" + + def __init__( + self, command: Callable, results_per_page: Optional[int] = None, continuation_token: Optional[str] = None + ) -> None: + super(FileRangePaged, self).__init__( + get_next=self._get_next_cb, extract_data=self._extract_data_cb, continuation_token=continuation_token or "" + ) + self._command = command + self.results_per_page = results_per_page + self.location_mode = None + self.current_page = [] + + def _get_next_cb(self, continuation_token): + try: + return self._command( + marker=continuation_token or None, + maxresults=self.results_per_page, + cls=return_context_and_deserialized, + use_location=self.location_mode, + ) + except HttpResponseError as error: + process_storage_error(error) + + def _extract_data_cb(self, get_next_return): + self.location_mode, self._response = get_next_return + self.current_page = parse_file_range_list(self._response) + return self._response.next_marker or None, self.current_page + + class CopyProperties(DictMixin): """File Copy Properties. diff --git a/sdk/storage/azure-storage-file-share/azure/storage/fileshare/_serialize.py b/sdk/storage/azure-storage-file-share/azure/storage/fileshare/_serialize.py index e79b8fdb6c8a..397df61ceb8e 100644 --- a/sdk/storage/azure-storage-file-share/azure/storage/fileshare/_serialize.py +++ b/sdk/storage/azure-storage-file-share/azure/storage/fileshare/_serialize.py @@ -53,6 +53,7 @@ "2026-02-06", "2026-04-06", "2026-06-06", + "2026-10-06", ] diff --git a/sdk/storage/azure-storage-file-share/azure/storage/fileshare/aio/_file_client_async.py b/sdk/storage/azure-storage-file-share/azure/storage/fileshare/aio/_file_client_async.py index 6b01b33889a0..6168f07e0104 100644 --- a/sdk/storage/azure-storage-file-share/azure/storage/fileshare/aio/_file_client_async.py +++ b/sdk/storage/azure-storage-file-share/azure/storage/fileshare/aio/_file_client_async.py @@ -63,12 +63,13 @@ from .._shared.validation import CV_TYPE_PARSED, is_crc64_validation, parse_validation_option from ._download_async import StorageStreamDownloader from ._lease_async import ShareLeaseClient -from ._models import FileProperties, Handle, HandlesPaged +from ._models import FileProperties, FileRangePaged, Handle, HandlesPaged if TYPE_CHECKING: from azure.core.credentials import AzureNamedKeyCredential, AzureSasCredential from azure.core.credentials_async import AsyncTokenCredential from .._models import ContentSettings, NTFSAttributes + from .._models import FileRange from .._shared.base_client import StorageConfiguration @@ -1504,9 +1505,12 @@ async def upload_range_from_url( async def get_ranges( self, offset: Optional[int] = None, length: Optional[int] = None, **kwargs: Any ) -> List[Dict[str, int]]: - """Returns the list of valid page ranges for a file or snapshot + """DEPRECATED: Returns the list of valid page ranges for a file or snapshot of a file. + .. deprecated:: + Use :func:`list_ranges` instead. + :param int offset: Specifies the start offset of bytes over which to get ranges. :param int length: @@ -1528,6 +1532,8 @@ async def get_ranges( A list of valid ranges. :rtype: List[dict[str, int]] """ + warnings.warn("get_ranges is deprecated, use list_ranges instead", DeprecationWarning) + options = _get_ranges_options(snapshot=self.snapshot, offset=offset, length=length, **kwargs) try: ranges = await self._client.file.get_range_list(**options) @@ -1535,6 +1541,37 @@ async def get_ranges( process_storage_error(error) return [{"start": file_range.start, "end": file_range.end} for file_range in ranges.ranges] + @distributed_trace + def list_ranges( + self, *, offset: Optional[int] = None, length: Optional[int] = None, **kwargs: Any + ) -> AsyncItemPaged["FileRange"]: + """Returns the list of valid ranges for a file or snapshot of a file. + + :keyword int offset: + Specifies the start offset of bytes over which to get ranges. + :keyword int length: + Number of bytes to use over which to get ranges. + :keyword lease: + Required if the file has an active lease. Value can be a ShareLeaseClient object + or the lease ID as a string. + :paramtype lease: ~azure.storage.fileshare.ShareLeaseClient or str + :keyword int results_per_page: + The maximum number of file ranges to retrieve per API call. + :keyword int timeout: + Sets the server-side timeout for the operation in seconds. For more details see + https://learn.microsoft.com/rest/api/storageservices/setting-timeouts-for-file-service-operations. + This value is not tracked or validated on the client. To configure client-side network timesouts + see `here `__. + :returns: + An iterable (auto-paging) of valid ranges. + :rtype: ~azure.core.async_paging.AsyncItemPaged[~azure.storage.fileshare.FileRange] + """ + results_per_page = kwargs.pop("results_per_page", None) + options = _get_ranges_options(snapshot=self.snapshot, offset=offset, length=length, **kwargs) + command = functools.partial(self._client.file.get_range_list, **options) + return AsyncItemPaged(command, results_per_page=results_per_page, page_iterator_class=FileRangePaged) + @distributed_trace_async async def get_ranges_diff( self, @@ -1545,10 +1582,11 @@ async def get_ranges_diff( include_renames: Optional[bool] = None, **kwargs: Any, ) -> Tuple[List[Dict[str, int]], List[Dict[str, int]]]: - """Returns the list of valid page ranges for a file or snapshot + """DEPRECATED: Returns the list of valid page ranges for a file or snapshot of a file. - .. versionadded:: 12.6.0 + .. deprecated:: + Use :func:`list_ranges_diff` instead. :param int offset: Specifies the start offset of bytes over which to get ranges. @@ -1578,6 +1616,8 @@ async def get_ranges_diff( The first element are filled file ranges, the 2nd element is cleared file ranges. :rtype: tuple[list[dict[str, int]], list[dict[str, int]]] """ + warnings.warn("get_ranges_diff is deprecated, use list_ranges_diff instead", DeprecationWarning) + options = _get_ranges_options( snapshot=self.snapshot, offset=offset, @@ -1592,6 +1632,61 @@ async def get_ranges_diff( process_storage_error(error) return get_file_ranges_result(ranges) + @distributed_trace + def list_ranges_diff( + self, + previous_sharesnapshot: Union[str, Dict[str, Any]], + *, + offset: Optional[int] = None, + length: Optional[int] = None, + include_renames: Optional[bool] = None, + **kwargs: Any, + ) -> AsyncItemPaged["FileRange"]: + """Returns the list of valid ranges for a file or snapshot of a file, listing only the + ranges that changed between the target snapshot (or live file) and a previous snapshot. + + :param str previous_sharesnapshot: + The snapshot diff parameter that contains an opaque DateTime value that + specifies a previous file snapshot to be compared + against a more recent snapshot or the current file. + :keyword int offset: + Specifies the start offset of bytes over which to get ranges. + :keyword int length: + Number of bytes to use over which to get ranges. + :keyword Optional[bool] include_renames: + Only valid if previous_sharesnapshot parameter is provided. Specifies whether the changed ranges for + a file that has been renamed or moved between the target snapshot (or live file) and the previous + snapshot should be listed. If set to True, the valid changed ranges for the file will be returned. + If set to False, the operation will result in a 409 (Conflict) response. + :keyword lease: + Required if the file has an active lease. Value can be a ShareLeaseClient object + or the lease ID as a string. + :paramtype lease: ~azure.storage.fileshare.ShareLeaseClient or str + :keyword int results_per_page: + The maximum number of file ranges to retrieve per API call. + :keyword int timeout: + Sets the server-side timeout for the operation in seconds. For more details see + https://learn.microsoft.com/rest/api/storageservices/setting-timeouts-for-file-service-operations. + This value is not tracked or validated on the client. To configure client-side network timesouts + see `here `__. + :returns: + An iterable (auto-paging) of valid ranges. Each range has a ``cleared`` attribute indicating + whether the range was cleared between the previous snapshot and the target. + :rtype: ~azure.core.async_paging.AsyncItemPaged[~azure.storage.fileshare.FileRange] + """ + results_per_page = kwargs.pop("results_per_page", None) + options = _get_ranges_options( + snapshot=self.snapshot, + offset=offset, + length=length, + previous_sharesnapshot=previous_sharesnapshot, + support_rename=include_renames, + **kwargs, + ) + command = functools.partial(self._client.file.get_range_list, **options) + return AsyncItemPaged(command, results_per_page=results_per_page, page_iterator_class=FileRangePaged) + @distributed_trace_async async def clear_range(self, offset: int, length: int, **kwargs: Any) -> Dict[str, Any]: """Clears the specified range and releases the space used in storage for diff --git a/sdk/storage/azure-storage-file-share/azure/storage/fileshare/aio/_file_client_async.pyi b/sdk/storage/azure-storage-file-share/azure/storage/fileshare/aio/_file_client_async.pyi index 49555a8964f4..26155149da72 100644 --- a/sdk/storage/azure-storage-file-share/azure/storage/fileshare/aio/_file_client_async.pyi +++ b/sdk/storage/azure-storage-file-share/azure/storage/fileshare/aio/_file_client_async.pyi @@ -34,7 +34,7 @@ from azure.core.tracing.decorator_async import distributed_trace_async from ._download_async import StorageStreamDownloader from ._lease_async import ShareLeaseClient from ._models import FileProperties, Handle -from .._models import ContentSettings, NTFSAttributes +from .._models import ContentSettings, FileRange, NTFSAttributes from .._shared.base_client import StorageAccountHostsMixin from .._shared.base_client_async import AsyncStorageAccountHostsMixin @@ -306,6 +306,17 @@ class ShareFileClient(AsyncStorageAccountHostsMixin, StorageAccountHostsMixin): timeout: Optional[int] = None, **kwargs: Any ) -> List[Dict[str, int]]: ... + @distributed_trace + def list_ranges( + self, + *, + offset: Optional[int] = None, + length: Optional[int] = None, + lease: Optional[Union[ShareLeaseClient, str]] = None, + results_per_page: Optional[int] = None, + timeout: Optional[int] = None, + **kwargs: Any + ) -> AsyncItemPaged[FileRange]: ... @distributed_trace_async async def get_ranges_diff( self, @@ -318,6 +329,19 @@ class ShareFileClient(AsyncStorageAccountHostsMixin, StorageAccountHostsMixin): timeout: Optional[int] = None, **kwargs: Any ) -> Tuple[List[Dict[str, int]], List[Dict[str, int]]]: ... + @distributed_trace + def list_ranges_diff( + self, + previous_sharesnapshot: Union[str, Dict[str, Any]], + *, + offset: Optional[int] = None, + length: Optional[int] = None, + include_renames: Optional[bool] = None, + lease: Optional[Union[ShareLeaseClient, str]] = None, + results_per_page: Optional[int] = None, + timeout: Optional[int] = None, + **kwargs: Any + ) -> AsyncItemPaged[FileRange]: ... @distributed_trace_async async def clear_range( self, diff --git a/sdk/storage/azure-storage-file-share/azure/storage/fileshare/aio/_models.py b/sdk/storage/azure-storage-file-share/azure/storage/fileshare/aio/_models.py index f52f4a023857..e65384d26e71 100644 --- a/sdk/storage/azure-storage-file-share/azure/storage/fileshare/aio/_models.py +++ b/sdk/storage/azure-storage-file-share/azure/storage/fileshare/aio/_models.py @@ -12,7 +12,7 @@ from .._shared.response_handlers import return_context_and_deserialized, process_storage_error from .._generated.models import DirectoryItem -from .._models import Handle, ShareProperties, DirectoryProperties, FileProperties +from .._models import FileRange, Handle, ShareProperties, DirectoryProperties, FileProperties, parse_file_range_list def _wrap_item(item): @@ -210,3 +210,47 @@ async def _extract_data_cb(self, get_next_return): for i in self._response.segment.file_items ) return self._response.next_marker or None, self.current_page + + +class FileRangePaged(AsyncPageIterator): + """An iterable of File Ranges. + + :param Callable command: Function to retrieve the next page of items. + :param Optional[int] results_per_page: The maximum number of file ranges to retrieve per call. + :param Optional[str] continuation_token: An opaque continuation token to retrieve the next page of results. + """ + + results_per_page: Optional[int] = None + """The maximum number of results retrieved per API call.""" + location_mode: Optional[str] = None + """The location mode being used to list results. + The available options include "primary" and "secondary".""" + current_page: List[FileRange] + """The current page of listed results.""" + + def __init__( + self, command: Callable, results_per_page: Optional[int] = None, continuation_token: Optional[str] = None + ) -> None: + super(FileRangePaged, self).__init__( + get_next=self._get_next_cb, extract_data=self._extract_data_cb, continuation_token=continuation_token or "" + ) + self._command = command + self.results_per_page = results_per_page + self.location_mode = None + self.current_page = [] + + async def _get_next_cb(self, continuation_token): + try: + return await self._command( + marker=continuation_token or None, + maxresults=self.results_per_page, + cls=return_context_and_deserialized, + use_location=self.location_mode, + ) + except HttpResponseError as error: + process_storage_error(error) + + async def _extract_data_cb(self, get_next_return): + self.location_mode, self._response = get_next_return + self.current_page = parse_file_range_list(self._response) + return self._response.next_marker or None, self.current_page diff --git a/sdk/storage/azure-storage-file-share/tests/test_file.py b/sdk/storage/azure-storage-file-share/tests/test_file.py index 744a29971b13..ceffae79c127 100644 --- a/sdk/storage/azure-storage-file-share/tests/test_file.py +++ b/sdk/storage/azure-storage-file-share/tests/test_file.py @@ -34,6 +34,7 @@ ShareServiceClient, StorageErrorCode, ) +from azure.storage.fileshare import FileRange # ------------------------------------------------------------------------------ TEST_SHARE_PREFIX = "share" @@ -1192,7 +1193,7 @@ def test_set_file_metadata_with_upper_case(self, **kwargs): assert md["hello"] == "world" assert md["number"] == "42" assert md["UP"] == "UPval" - assert not "up" in md + assert "up" not in md @FileSharePreparer() @recorded_by_proxy @@ -1222,7 +1223,7 @@ def test_set_file_metadata_with_oauth(self, **kwargs): assert md["hello"] == "world" assert md["number"] == "42" assert md["UP"] == "UPval" - assert not "up" in md + assert "up" not in md @FileSharePreparer() @recorded_by_proxy @@ -1274,7 +1275,7 @@ def test_set_file_metadata_with_broken_lease(self, **kwargs): assert md["hello"] == "world" assert md["number"] == "42" assert md["UP"] == "UPval" - assert not "up" in md + assert "up" not in md @FileSharePreparer() @recorded_by_proxy @@ -1919,6 +1920,173 @@ def test_list_ranges_none_with_invalid_lease_fails(self, **kwargs): assert ranges is not None assert len(ranges) == 0 + @FileSharePreparer() + @recorded_by_proxy + def test_list_ranges(self, **kwargs): + storage_account_name = kwargs.pop("storage_account_name") + storage_account_key = kwargs.pop("storage_account_key") + + self._setup(storage_account_name, storage_account_key) + file_name = self._get_file_reference() + file_client = ShareFileClient( + self.account_url(storage_account_name, "file"), + share_name=self.share_name, + file_path=file_name, + credential=storage_account_key.secret, + ) + file_client.create_file(2560) + data = b"abcdefghijklmnop" * 32 + file_client.upload_range(data, offset=0, length=512) + file_client.upload_range(data * 2, offset=1024, length=1024) + + # Act + ranges = list(file_client.list_ranges()) + + # Assert + assert ranges is not None + assert len(ranges) == 2 + assert isinstance(ranges[0], FileRange) + assert ranges[0].start == 0 + assert ranges[0].end == 511 + assert not ranges[0].cleared + assert ranges[1].start == 1024 + assert ranges[1].end == 2047 + assert not ranges[1].cleared + + @FileSharePreparer() + @recorded_by_proxy + def test_list_ranges_empty(self, **kwargs): + storage_account_name = kwargs.pop("storage_account_name") + storage_account_key = kwargs.pop("storage_account_key") + + self._setup(storage_account_name, storage_account_key) + file_name = self._get_file_reference() + file_client = ShareFileClient( + self.account_url(storage_account_name, "file"), + share_name=self.share_name, + file_path=file_name, + credential=storage_account_key.secret, + ) + file_client.create_file(1024) + + # Act + ranges = list(file_client.list_ranges()) + + # Assert + assert ranges is not None + assert isinstance(ranges, list) + assert len(ranges) == 0 + + @FileSharePreparer() + @recorded_by_proxy + def test_list_ranges_offset(self, **kwargs): + storage_account_name = kwargs.pop("storage_account_name") + storage_account_key = kwargs.pop("storage_account_key") + + self._setup(storage_account_name, storage_account_key) + file_name = self._get_file_reference() + file_client = ShareFileClient( + self.account_url(storage_account_name, "file"), + share_name=self.share_name, + file_path=file_name, + credential=storage_account_key.secret, + ) + file_client.create_file(2560) + data = b"abcdefghijklmnop" * 32 + file_client.upload_range(data * 3, offset=0, length=1536) + file_client.upload_range(data, offset=2048, length=512) + + # Act + ranges = list(file_client.list_ranges(offset=1024, length=1024)) + + # Assert + assert ranges is not None + assert isinstance(ranges, list) + assert len(ranges) == 1 + assert ranges[0].start == 1024 + assert ranges[0].end == 1535 + assert not ranges[0].cleared + + @FileSharePreparer() + @recorded_by_proxy + def test_list_ranges_pagination(self, **kwargs): + storage_account_name = kwargs.pop("storage_account_name") + storage_account_key = kwargs.pop("storage_account_key") + + self._setup(storage_account_name, storage_account_key) + file_name = self._get_file_reference() + file_client = ShareFileClient( + self.account_url(storage_account_name, "file"), + share_name=self.share_name, + file_path=file_name, + credential=storage_account_key.secret, + ) + file_client.create_file(3072) + data = b"abcdefghijklmnop" * 32 + file_client.upload_range(data, offset=0, length=512) + file_client.upload_range(data, offset=1024, length=512) + file_client.upload_range(data * 2, offset=2048, length=1024) + + # Act + page_list = file_client.list_ranges(results_per_page=2).by_page() + first_page = next(page_list) + items_on_page1 = list(first_page) + second_page = next(page_list) + items_on_page2 = list(second_page) + + # Assert + assert len(items_on_page1) == 2 + assert len(items_on_page2) == 1 + + @FileSharePreparer() + @recorded_by_proxy + def test_list_ranges_diff_paged(self, **kwargs): + storage_account_name = kwargs.pop("storage_account_name") + storage_account_key = kwargs.pop("storage_account_key") + + self._setup(storage_account_name, storage_account_key) + file_name = self._get_file_reference() + file_client = ShareFileClient( + self.account_url(storage_account_name, "file"), + share_name=self.share_name, + file_path=file_name, + credential=storage_account_key.secret, + ) + + file_client.create_file(2048) + share_client = self.fsc.get_share_client(self.share_name) + snapshot1 = share_client.create_snapshot() + + data = self.get_random_bytes(1536) + file_client.upload_range(data, offset=0, length=1536) + snapshot2 = share_client.create_snapshot() + file_client.clear_range(offset=512, length=512) + + # Act + ranges1 = list(file_client.list_ranges_diff(previous_sharesnapshot=snapshot1)) + ranges2 = list(file_client.list_ranges_diff(previous_sharesnapshot=snapshot2["snapshot"])) + + # Assert + assert ranges1 is not None + assert isinstance(ranges1, list) + assert len(ranges1) == 3 + assert ranges1[0].start == 0 + assert ranges1[0].end == 511 + assert not ranges1[0].cleared + assert ranges1[1].start == 512 + assert ranges1[1].end == 1023 + assert ranges1[1].cleared + assert ranges1[2].start == 1024 + assert ranges1[2].end == 1535 + assert not ranges1[2].cleared + + assert ranges2 is not None + assert isinstance(ranges2, list) + assert len(ranges2) == 1 + assert ranges2[0].start == 512 + assert ranges2[0].end == 1023 + assert ranges2[0].cleared + @FileSharePreparer() @recorded_by_proxy def test_list_ranges_diff(self, **kwargs): diff --git a/sdk/storage/azure-storage-file-share/tests/test_file_async.py b/sdk/storage/azure-storage-file-share/tests/test_file_async.py index 5d4962821f7d..750eb365721e 100644 --- a/sdk/storage/azure-storage-file-share/tests/test_file_async.py +++ b/sdk/storage/azure-storage-file-share/tests/test_file_async.py @@ -34,6 +34,7 @@ StorageErrorCode, ) from azure.storage.fileshare.aio import ShareFileClient, ShareServiceClient +from azure.storage.fileshare import FileRange # ------------------------------------------------------------------------------ TEST_SHARE_PREFIX = "share" @@ -1209,7 +1210,7 @@ async def test_set_file_metadata_with_upper_case(self, **kwargs): assert md["hello"] == "world" assert md["number"] == "42" assert md["UP"] == "UPval" - assert not "up" in md + assert "up" not in md @FileSharePreparer() @recorded_by_proxy_async @@ -1241,7 +1242,7 @@ async def test_set_file_metadata_with_oauth(self, **kwargs): assert md["hello"] == "world" assert md["number"] == "42" assert md["UP"] == "UPval" - assert not "up" in md + assert "up" not in md @FileSharePreparer() @recorded_by_proxy_async @@ -1293,7 +1294,7 @@ async def test_set_file_metadata_with_broken_lease(self, **kwargs): assert md["hello"] == "world" assert md["number"] == "42" assert md["UP"] == "UPval" - assert not "up" in md + assert "up" not in md @FileSharePreparer() @recorded_by_proxy_async @@ -1960,6 +1961,178 @@ async def test_list_ranges_none_with_invalid_lease_fails(self, **kwargs): assert ranges is not None assert len(ranges) == 0 + @FileSharePreparer() + @recorded_by_proxy_async + async def test_list_ranges(self, **kwargs): + storage_account_name = kwargs.pop("storage_account_name") + storage_account_key = kwargs.pop("storage_account_key") + + self._setup(storage_account_name, storage_account_key) + file_name = self._get_file_reference() + await self._setup_share(storage_account_name, storage_account_key) + file_client = ShareFileClient( + self.account_url(storage_account_name, "file"), + share_name=self.share_name, + file_path=file_name, + credential=storage_account_key.secret, + ) + await file_client.create_file(2560) + data = b"abcdefghijklmnop" * 32 + await file_client.upload_range(data, offset=0, length=512) + await file_client.upload_range(data * 2, offset=1024, length=1024) + + # Act + ranges = [r async for r in file_client.list_ranges()] + + # Assert + assert ranges is not None + assert len(ranges) == 2 + assert isinstance(ranges[0], FileRange) + assert ranges[0].start == 0 + assert ranges[0].end == 511 + assert not ranges[0].cleared + assert ranges[1].start == 1024 + assert ranges[1].end == 2047 + assert not ranges[1].cleared + + @FileSharePreparer() + @recorded_by_proxy_async + async def test_list_ranges_empty(self, **kwargs): + storage_account_name = kwargs.pop("storage_account_name") + storage_account_key = kwargs.pop("storage_account_key") + + self._setup(storage_account_name, storage_account_key) + file_name = self._get_file_reference() + await self._setup_share(storage_account_name, storage_account_key) + file_client = ShareFileClient( + self.account_url(storage_account_name, "file"), + share_name=self.share_name, + file_path=file_name, + credential=storage_account_key.secret, + ) + await file_client.create_file(1024) + + # Act + ranges = [r async for r in file_client.list_ranges()] + + # Assert + assert ranges is not None + assert isinstance(ranges, list) + assert len(ranges) == 0 + + @FileSharePreparer() + @recorded_by_proxy_async + async def test_list_ranges_offset(self, **kwargs): + storage_account_name = kwargs.pop("storage_account_name") + storage_account_key = kwargs.pop("storage_account_key") + + self._setup(storage_account_name, storage_account_key) + file_name = self._get_file_reference() + await self._setup_share(storage_account_name, storage_account_key) + file_client = ShareFileClient( + self.account_url(storage_account_name, "file"), + share_name=self.share_name, + file_path=file_name, + credential=storage_account_key.secret, + ) + await file_client.create_file(2560) + data = b"abcdefghijklmnop" * 32 + await file_client.upload_range(data * 3, offset=0, length=1536) + await file_client.upload_range(data, offset=2048, length=512) + + # Act + ranges = [r async for r in file_client.list_ranges(offset=1024, length=1024)] + + # Assert + assert ranges is not None + assert isinstance(ranges, list) + assert len(ranges) == 1 + assert ranges[0].start == 1024 + assert ranges[0].end == 1535 + assert not ranges[0].cleared + + @FileSharePreparer() + @recorded_by_proxy_async + async def test_list_ranges_pagination(self, **kwargs): + storage_account_name = kwargs.pop("storage_account_name") + storage_account_key = kwargs.pop("storage_account_key") + + self._setup(storage_account_name, storage_account_key) + file_name = self._get_file_reference() + await self._setup_share(storage_account_name, storage_account_key) + file_client = ShareFileClient( + self.account_url(storage_account_name, "file"), + share_name=self.share_name, + file_path=file_name, + credential=storage_account_key.secret, + ) + await file_client.create_file(3072) + data = b"abcdefghijklmnop" * 32 + await file_client.upload_range(data, offset=0, length=512) + await file_client.upload_range(data, offset=1024, length=512) + await file_client.upload_range(data * 2, offset=2048, length=1024) + + # Act + page_list = file_client.list_ranges(results_per_page=2).by_page() + first_page = await page_list.__anext__() + items_on_page1 = [r async for r in first_page] + second_page = await page_list.__anext__() + items_on_page2 = [r async for r in second_page] + + # Assert + assert len(items_on_page1) == 2 + assert len(items_on_page2) == 1 + + @FileSharePreparer() + @recorded_by_proxy_async + async def test_list_ranges_diff_paged(self, **kwargs): + storage_account_name = kwargs.pop("storage_account_name") + storage_account_key = kwargs.pop("storage_account_key") + + self._setup(storage_account_name, storage_account_key) + file_name = self._get_file_reference() + await self._setup_share(storage_account_name, storage_account_key) + file_client = ShareFileClient( + self.account_url(storage_account_name, "file"), + share_name=self.share_name, + file_path=file_name, + credential=storage_account_key.secret, + ) + + await file_client.create_file(2048) + share_client = self.fsc.get_share_client(self.share_name) + snapshot1 = await share_client.create_snapshot() + + data = self.get_random_bytes(1536) + await file_client.upload_range(data, offset=0, length=1536) + snapshot2 = await share_client.create_snapshot() + await file_client.clear_range(offset=512, length=512) + + # Act + ranges1 = [r async for r in file_client.list_ranges_diff(previous_sharesnapshot=snapshot1)] + ranges2 = [r async for r in file_client.list_ranges_diff(previous_sharesnapshot=snapshot2["snapshot"])] + + # Assert + assert ranges1 is not None + assert isinstance(ranges1, list) + assert len(ranges1) == 3 + assert ranges1[0].start == 0 + assert ranges1[0].end == 511 + assert not ranges1[0].cleared + assert ranges1[1].start == 512 + assert ranges1[1].end == 1023 + assert ranges1[1].cleared + assert ranges1[2].start == 1024 + assert ranges1[2].end == 1535 + assert not ranges1[2].cleared + + assert ranges2 is not None + assert isinstance(ranges2, list) + assert len(ranges2) == 1 + assert ranges2[0].start == 512 + assert ranges2[0].end == 1023 + assert ranges2[0].cleared + @FileSharePreparer() @recorded_by_proxy_async async def test_list_ranges_diff(self, **kwargs):