Skip to content

Commit fd21b06

Browse files
biefanromanlutzCopilot
authored
MAINT Support relative blob paths in AzureBlobStorageIO (#1478)
Co-authored-by: Roman Lutz <romanlutz13@gmail.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 803416a commit fd21b06

2 files changed

Lines changed: 135 additions & 5 deletions

File tree

pyrit/models/storage_io.py

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -255,6 +255,32 @@ def parse_blob_url(self, file_path: str) -> tuple[str, str]:
255255
return container_name, blob_name
256256
raise ValueError("Invalid blob URL")
257257

258+
def _resolve_blob_name(self, path: Union[Path, str]) -> str:
259+
"""
260+
Resolve a blob name from either a full blob URL or a relative blob path.
261+
262+
When a full URL is provided the blob name is extracted from it. The container
263+
name embedded in the URL is intentionally discarded — operations always run
264+
against the container configured in the constructor.
265+
266+
Backslashes are normalized to forward slashes so that ``Path`` objects
267+
created on Windows still produce valid blob names.
268+
269+
Args:
270+
path (Union[Path, str]): Blob URL or relative blob path.
271+
272+
Returns:
273+
str: The resolved blob name.
274+
275+
"""
276+
path_str = str(path).replace("\\", "/")
277+
try:
278+
# parse_blob_url validates scheme + netloc internally
279+
_, blob_name = self.parse_blob_url(path_str)
280+
return blob_name
281+
except ValueError:
282+
return path_str
283+
258284
async def read_file(self, path: Union[Path, str]) -> bytes:
259285
"""
260286
Asynchronously reads the content of a file (blob) from Azure Blob Storage.
@@ -285,7 +311,7 @@ async def read_file(self, path: Union[Path, str]) -> bytes:
285311
if not self._client_async:
286312
await self._create_container_client_async()
287313

288-
_, blob_name = self.parse_blob_url(str(path))
314+
blob_name = self._resolve_blob_name(path)
289315

290316
try:
291317
blob_client = self._client_async.get_blob_client(blob=blob_name)
@@ -305,14 +331,17 @@ async def write_file(self, path: Union[Path, str], data: bytes) -> None:
305331
"""
306332
Write data to Azure Blob Storage at the specified path.
307333
334+
If the provided ``path`` is a full URL, the blob name is extracted from it.
335+
If a relative path is provided, it is used as the blob name directly.
336+
308337
Args:
309-
path (str): The full Azure Blob Storage URL
338+
path (Union[Path, str]): Full blob URL or relative blob path.
310339
data (bytes): The data to write.
311340
312341
"""
313342
if not self._client_async:
314343
await self._create_container_client_async()
315-
_, blob_name = self.parse_blob_url(str(path))
344+
blob_name = self._resolve_blob_name(path)
316345
try:
317346
await self._upload_blob_async(file_name=blob_name, data=data, content_type=self._blob_content_type)
318347
except Exception as exc:
@@ -336,7 +365,7 @@ async def path_exists(self, path: Union[Path, str]) -> bool:
336365
if not self._client_async:
337366
await self._create_container_client_async()
338367
try:
339-
_, blob_name = self.parse_blob_url(str(path))
368+
blob_name = self._resolve_blob_name(path)
340369
blob_client = self._client_async.get_blob_client(blob=blob_name)
341370
await blob_client.get_blob_properties()
342371
return True
@@ -360,7 +389,7 @@ async def is_file(self, path: Union[Path, str]) -> bool:
360389
if not self._client_async:
361390
await self._create_container_client_async()
362391
try:
363-
_, blob_name = self.parse_blob_url(str(path))
392+
blob_name = self._resolve_blob_name(path)
364393
blob_client = self._client_async.get_blob_client(blob=blob_name)
365394
blob_properties = await blob_client.get_blob_properties()
366395
return blob_properties.size > 0

tests/unit/models/test_storage_io.py

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,25 @@ async def test_azure_blob_storage_io_read_file(azure_blob_storage_io):
101101
assert result == b"Test file content"
102102

103103

104+
@pytest.mark.asyncio
105+
async def test_azure_blob_storage_io_read_file_with_relative_path(azure_blob_storage_io):
106+
mock_container_client = AsyncMock()
107+
azure_blob_storage_io._client_async = mock_container_client
108+
109+
mock_blob_client = AsyncMock()
110+
mock_blob_stream = AsyncMock()
111+
112+
mock_container_client.get_blob_client = Mock(return_value=mock_blob_client)
113+
mock_blob_client.download_blob = AsyncMock(return_value=mock_blob_stream)
114+
mock_blob_stream.readall = AsyncMock(return_value=b"Test file content")
115+
mock_container_client.close = AsyncMock()
116+
117+
result = await azure_blob_storage_io.read_file("dir1/dir2/sample.png")
118+
119+
assert result == b"Test file content"
120+
mock_container_client.get_blob_client.assert_called_once_with(blob="dir1/dir2/sample.png")
121+
122+
104123
@pytest.mark.asyncio
105124
async def test_azure_blob_storage_io_write_file():
106125
container_url = "https://youraccount.blob.core.windows.net/yourcontainer"
@@ -129,6 +148,29 @@ async def test_azure_blob_storage_io_write_file():
129148
)
130149

131150

151+
@pytest.mark.asyncio
152+
async def test_azure_blob_storage_io_write_file_with_relative_path():
153+
container_url = "https://youraccount.blob.core.windows.net/yourcontainer"
154+
azure_blob_storage_io = AzureBlobStorageIO(
155+
container_url=container_url, blob_content_type=SupportedContentType.PLAIN_TEXT
156+
)
157+
158+
mock_container_client = AsyncMock()
159+
160+
with patch.object(azure_blob_storage_io, "_create_container_client_async", return_value=None):
161+
azure_blob_storage_io._client_async = mock_container_client
162+
azure_blob_storage_io._upload_blob_async = AsyncMock()
163+
164+
data_to_write = b"Test data"
165+
await azure_blob_storage_io.write_file("dir1/dir2/testfile.txt", data_to_write)
166+
167+
azure_blob_storage_io._upload_blob_async.assert_awaited_with(
168+
file_name="dir1/dir2/testfile.txt",
169+
data=data_to_write,
170+
content_type=SupportedContentType.PLAIN_TEXT.value,
171+
)
172+
173+
132174
@pytest.mark.asyncio
133175
async def test_azure_blob_storage_io_create_container_client_uses_explicit_sas_token():
134176
container_url = "https://youraccount.blob.core.windows.net/yourcontainer"
@@ -164,6 +206,23 @@ async def test_azure_storage_io_path_exists(azure_blob_storage_io):
164206
assert exists is True
165207

166208

209+
@pytest.mark.asyncio
210+
async def test_azure_storage_io_path_exists_with_relative_path(azure_blob_storage_io):
211+
mock_container_client = AsyncMock()
212+
azure_blob_storage_io._client_async = mock_container_client
213+
214+
mock_blob_client = AsyncMock()
215+
216+
mock_container_client.get_blob_client = Mock(return_value=mock_blob_client)
217+
mock_blob_client.get_blob_properties = AsyncMock()
218+
mock_container_client.close = AsyncMock()
219+
220+
exists = await azure_blob_storage_io.path_exists("dir1/dir2/blob_name.txt")
221+
222+
assert exists is True
223+
mock_container_client.get_blob_client.assert_called_once_with(blob="dir1/dir2/blob_name.txt")
224+
225+
167226
@pytest.mark.asyncio
168227
async def test_azure_storage_io_is_file(azure_blob_storage_io):
169228
azure_blob_storage_io._client_async = AsyncMock()
@@ -179,6 +238,24 @@ async def test_azure_storage_io_is_file(azure_blob_storage_io):
179238
assert is_file is True
180239

181240

241+
@pytest.mark.asyncio
242+
async def test_azure_storage_io_is_file_with_relative_path(azure_blob_storage_io):
243+
mock_container_client = AsyncMock()
244+
azure_blob_storage_io._client_async = mock_container_client
245+
246+
mock_blob_client = AsyncMock()
247+
248+
mock_container_client.get_blob_client = Mock(return_value=mock_blob_client)
249+
mock_blob_properties = Mock(size=1024)
250+
mock_blob_client.get_blob_properties = AsyncMock(return_value=mock_blob_properties)
251+
mock_container_client.close = AsyncMock()
252+
253+
is_file = await azure_blob_storage_io.is_file("dir1/dir2/blob_name.txt")
254+
255+
assert is_file is True
256+
mock_container_client.get_blob_client.assert_called_once_with(blob="dir1/dir2/blob_name.txt")
257+
258+
182259
def test_azure_storage_io_parse_blob_url_valid(azure_blob_storage_io):
183260
file_path = "https://example.blob.core.windows.net/container/dir1/dir2/blob_name.txt"
184261
container_name, blob_name = azure_blob_storage_io.parse_blob_url(file_path)
@@ -200,3 +277,27 @@ def test_azure_storage_io_parse_blob_url_without_scheme(azure_blob_storage_io):
200277
def test_azure_storage_io_parse_blob_url_without_netloc(azure_blob_storage_io):
201278
with pytest.raises(ValueError, match="Invalid blob URL"):
202279
azure_blob_storage_io.parse_blob_url("https:///container/dir1/blob_name.txt")
280+
281+
282+
def test_resolve_blob_name_with_full_url(azure_blob_storage_io):
283+
result = azure_blob_storage_io._resolve_blob_name("https://account.blob.core.windows.net/container/dir1/file.txt")
284+
assert result == "dir1/file.txt"
285+
286+
287+
def test_resolve_blob_name_with_relative_path(azure_blob_storage_io):
288+
assert azure_blob_storage_io._resolve_blob_name("dir1/dir2/file.txt") == "dir1/dir2/file.txt"
289+
290+
291+
def test_resolve_blob_name_with_simple_filename(azure_blob_storage_io):
292+
assert azure_blob_storage_io._resolve_blob_name("file.txt") == "file.txt"
293+
294+
295+
def test_resolve_blob_name_normalizes_backslashes(azure_blob_storage_io):
296+
assert azure_blob_storage_io._resolve_blob_name("dir1\\dir2\\file.txt") == "dir1/dir2/file.txt"
297+
298+
299+
def test_resolve_blob_name_with_path_object(azure_blob_storage_io):
300+
from pathlib import PurePosixPath
301+
302+
result = azure_blob_storage_io._resolve_blob_name(PurePosixPath("dir1/dir2/file.txt"))
303+
assert result == "dir1/dir2/file.txt"

0 commit comments

Comments
 (0)