Skip to content

Commit ce48abc

Browse files
romanlutzCopilot
andauthored
MAINT: add _async suffix to async helpers in pyrit/common (#1744)
Co-authored-by: romanlutz <romanlutz@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent ea7ba88 commit ce48abc

17 files changed

Lines changed: 309 additions & 64 deletions

pyrit/common/data_url_converter.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
11
# Copyright (c) Microsoft Corporation.
22
# Licensed under the MIT license.
33

4+
from pyrit.common.deprecation import print_deprecation_message
45
from pyrit.models import DataTypeSerializer, data_serializer_factory
56

67
# Supported image formats for Azure OpenAI GPT-4o,
78
# https://learn.microsoft.com/en-us/azure/ai-services/openai/concepts/use-your-image-data
89
AZURE_OPENAI_GPT4O_SUPPORTED_IMAGE_FORMATS = [".jpg", ".jpeg", ".png", ".gif", ".bmp", ".tiff", ".tif"]
910

1011

11-
async def convert_local_image_to_data_url(image_path: str) -> str:
12+
async def convert_local_image_to_data_url_async(image_path: str) -> str:
1213
"""
1314
Convert a local image file to a data URL encoded in base64.
1415
@@ -42,3 +43,18 @@ async def convert_local_image_to_data_url(image_path: str) -> str:
4243
# Construct the data URL, as per Azure OpenAI GPT-4 Turbo local image format
4344
# https://learn.microsoft.com/en-us/azure/ai-services/openai/how-to/gpt-with-vision?tabs=rest%2Csystem-assigned%2Cresource#call-the-chat-completion-apis
4445
return f"data:{mime_type};base64,{base64_encoded_data}"
46+
47+
48+
async def convert_local_image_to_data_url(image_path: str) -> str:
49+
"""
50+
Delegate to :func:`convert_local_image_to_data_url_async` (deprecated alias).
51+
52+
Returns:
53+
str: A string containing the MIME type and the base64-encoded data of the image, formatted as a data URL.
54+
"""
55+
print_deprecation_message(
56+
old_item="pyrit.common.data_url_converter.convert_local_image_to_data_url",
57+
new_item="pyrit.common.data_url_converter.convert_local_image_to_data_url_async",
58+
removed_in="0.16.0",
59+
)
60+
return await convert_local_image_to_data_url_async(image_path)

pyrit/common/display_response.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,15 @@
66

77
from PIL import Image
88

9+
from pyrit.common.deprecation import print_deprecation_message
910
from pyrit.common.notebook_utils import is_in_ipython_session
1011
from pyrit.memory import CentralMemory
1112
from pyrit.models import AzureBlobStorageIO, DiskStorageIO, MessagePiece
1213

1314
logger = logging.getLogger(__name__)
1415

1516

16-
async def display_image_response(response_piece: MessagePiece) -> None:
17+
async def display_image_response_async(response_piece: MessagePiece) -> None:
1718
"""
1819
Display response images if running in notebook environment.
1920
@@ -54,3 +55,13 @@ async def display_image_response(response_piece: MessagePiece) -> None:
5455
display(image) # type: ignore[ty:unresolved-reference] # noqa: F821
5556
if response_piece.response_error == "blocked":
5657
logger.info("---\nContent blocked, cannot show a response.\n---")
58+
59+
60+
async def display_image_response(response_piece: MessagePiece) -> None:
61+
"""Delegate to :func:`display_image_response_async` (deprecated alias)."""
62+
print_deprecation_message(
63+
old_item="pyrit.common.display_response.display_image_response",
64+
new_item="pyrit.common.display_response.display_image_response_async",
65+
removed_in="0.16.0",
66+
)
67+
await display_image_response_async(response_piece)

pyrit/common/download_hf_model.py

Lines changed: 62 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99
import httpx
1010
from huggingface_hub import HfApi
1111

12+
from pyrit.common.deprecation import print_deprecation_message
13+
1214
logger = logging.getLogger(__name__)
1315

1416

@@ -37,7 +39,9 @@ def get_available_files(model_id: str, token: str) -> list[str]:
3739
return []
3840

3941

40-
async def download_specific_files(model_id: str, file_patterns: list[str] | None, token: str, cache_dir: Path) -> None:
42+
async def download_specific_files_async(
43+
model_id: str, file_patterns: list[str] | None, token: str, cache_dir: Path
44+
) -> None:
4145
"""
4246
Download specific files from a Hugging Face model repository.
4347
If file_patterns is None, downloads all files.
@@ -61,10 +65,12 @@ async def download_specific_files(model_id: str, file_patterns: list[str] | None
6165
urls = [base_url + file for file in files_to_download]
6266

6367
# Download the files
64-
await download_files(urls, token, cache_dir)
68+
await download_files_async(urls, token, cache_dir)
6569

6670

67-
async def download_chunk(url: str, headers: dict[str, str], start: int, end: int, client: httpx.AsyncClient) -> bytes:
71+
async def download_chunk_async(
72+
url: str, headers: dict[str, str], start: int, end: int, client: httpx.AsyncClient
73+
) -> bytes:
6874
"""
6975
Download a chunk of the file with a specified byte range.
7076
@@ -77,7 +83,7 @@ async def download_chunk(url: str, headers: dict[str, str], start: int, end: int
7783
return response.content
7884

7985

80-
async def download_file(url: str, token: str, download_dir: Path, num_splits: int) -> None:
86+
async def download_file_async(url: str, token: str, download_dir: Path, num_splits: int) -> None:
8187
"""Download a file in multiple segments (splits) using byte-range requests."""
8288
headers = {"Authorization": f"Bearer {token}"}
8389
async with httpx.AsyncClient(follow_redirects=True) as client:
@@ -95,7 +101,7 @@ async def download_file(url: str, token: str, download_dir: Path, num_splits: in
95101
for i in range(num_splits):
96102
start = i * chunk_size
97103
end = start + chunk_size - 1 if i < num_splits - 1 else file_size - 1
98-
tasks.append(download_chunk(url, headers, start, end, client))
104+
tasks.append(download_chunk_async(url, headers, start, end, client))
99105

100106
# Download all chunks concurrently
101107
chunks = await asyncio.gather(*tasks)
@@ -107,16 +113,63 @@ async def download_file(url: str, token: str, download_dir: Path, num_splits: in
107113
logger.info(f"Downloaded {file_name} to {file_path}")
108114

109115

110-
async def download_files(
116+
async def download_files_async(
111117
urls: list[str], token: str, download_dir: Path, num_splits: int = 3, parallel_downloads: int = 4
112118
) -> None:
113119
"""Download multiple files with parallel downloads and segmented downloading."""
114120
# Limit the number of parallel downloads
115121
semaphore = asyncio.Semaphore(parallel_downloads)
116122

117-
async def download_with_limit(url: str) -> None:
123+
async def download_with_limit_async(url: str) -> None:
118124
async with semaphore:
119-
await download_file(url, token, download_dir, num_splits)
125+
await download_file_async(url, token, download_dir, num_splits)
120126

121127
# Run downloads concurrently, but limit to parallel_downloads at a time
122-
await asyncio.gather(*(download_with_limit(url) for url in urls))
128+
await asyncio.gather(*(download_with_limit_async(url) for url in urls))
129+
130+
131+
async def download_specific_files(model_id: str, file_patterns: list[str] | None, token: str, cache_dir: Path) -> None:
132+
"""Delegate to :func:`download_specific_files_async` (deprecated alias)."""
133+
print_deprecation_message(
134+
old_item="pyrit.common.download_hf_model.download_specific_files",
135+
new_item="pyrit.common.download_hf_model.download_specific_files_async",
136+
removed_in="0.16.0",
137+
)
138+
await download_specific_files_async(model_id, file_patterns, token, cache_dir)
139+
140+
141+
async def download_chunk(url: str, headers: dict[str, str], start: int, end: int, client: httpx.AsyncClient) -> bytes:
142+
"""
143+
Delegate to :func:`download_chunk_async` (deprecated alias).
144+
145+
Returns:
146+
The content of the downloaded chunk.
147+
"""
148+
print_deprecation_message(
149+
old_item="pyrit.common.download_hf_model.download_chunk",
150+
new_item="pyrit.common.download_hf_model.download_chunk_async",
151+
removed_in="0.16.0",
152+
)
153+
return await download_chunk_async(url, headers, start, end, client)
154+
155+
156+
async def download_file(url: str, token: str, download_dir: Path, num_splits: int) -> None:
157+
"""Delegate to :func:`download_file_async` (deprecated alias)."""
158+
print_deprecation_message(
159+
old_item="pyrit.common.download_hf_model.download_file",
160+
new_item="pyrit.common.download_hf_model.download_file_async",
161+
removed_in="0.16.0",
162+
)
163+
await download_file_async(url, token, download_dir, num_splits)
164+
165+
166+
async def download_files(
167+
urls: list[str], token: str, download_dir: Path, num_splits: int = 3, parallel_downloads: int = 4
168+
) -> None:
169+
"""Delegate to :func:`download_files_async` (deprecated alias)."""
170+
print_deprecation_message(
171+
old_item="pyrit.common.download_hf_model.download_files",
172+
new_item="pyrit.common.download_hf_model.download_files_async",
173+
removed_in="0.16.0",
174+
)
175+
await download_files_async(urls, token, download_dir, num_splits, parallel_downloads)

pyrit/message_normalizer/chat_message_normalizer.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import os
77
from typing import TYPE_CHECKING, Any, Union
88

9-
from pyrit.common.data_url_converter import convert_local_image_to_data_url
9+
from pyrit.common.data_url_converter import convert_local_image_to_data_url_async
1010
from pyrit.message_normalizer.message_normalizer import (
1111
MessageListNormalizer,
1212
MessageStringNormalizer,
@@ -140,7 +140,7 @@ async def _piece_to_content_dict_async(self, piece: MessagePiece) -> dict[str, A
140140
return {"type": "text", "text": content}
141141
if data_type == "image_path":
142142
# Convert local image to base64 data URL
143-
data_url = await convert_local_image_to_data_url(content)
143+
data_url = await convert_local_image_to_data_url_async(content)
144144
return {"type": "image_url", "image_url": {"url": data_url}}
145145
if data_type == "audio_path":
146146
# Convert local audio to base64 for input_audio format

pyrit/prompt_target/hugging_face/hugging_face_chat_target.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
)
1818

1919
from pyrit.common import default_values
20-
from pyrit.common.download_hf_model import download_specific_files
20+
from pyrit.common.download_hf_model import download_specific_files_async
2121
from pyrit.exceptions import EmptyResponseException, pyrit_target_retry
2222
from pyrit.identifiers import ComponentIdentifier
2323
from pyrit.models import Message, construct_response_from_request
@@ -276,11 +276,16 @@ async def load_model_and_tokenizer(self) -> None:
276276
if self.necessary_files is None:
277277
# Download all files if no specific files are provided
278278
logger.info(f"Downloading all files for {self.model_id}...")
279-
await download_specific_files(self.model_id or "", None, self.huggingface_token, Path(cache_dir)) # type: ignore[ty:invalid-argument-type]
279+
await download_specific_files_async(
280+
self.model_id or "",
281+
None,
282+
self.huggingface_token, # type: ignore[ty:invalid-argument-type]
283+
Path(cache_dir),
284+
)
280285
else:
281286
# Download only the necessary files
282287
logger.info(f"Downloading specific files for {self.model_id}...")
283-
await download_specific_files(
288+
await download_specific_files_async(
284289
self.model_id or "",
285290
self.necessary_files,
286291
self.huggingface_token, # type: ignore[ty:invalid-argument-type]

pyrit/prompt_target/openai/openai_chat_target.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from collections.abc import MutableSequence
88
from typing import Any, Optional
99

10-
from pyrit.common.data_url_converter import convert_local_image_to_data_url
10+
from pyrit.common.data_url_converter import convert_local_image_to_data_url_async
1111
from pyrit.exceptions import (
1212
EmptyResponseException,
1313
PyritException,
@@ -613,7 +613,7 @@ async def _build_chat_messages_for_multi_modal_async(
613613
entry = {"type": "text", "text": message_piece.converted_value}
614614
content.append(entry)
615615
elif message_piece.converted_value_data_type == "image_path":
616-
data_base64_encoded_url = await convert_local_image_to_data_url(message_piece.converted_value)
616+
data_base64_encoded_url = await convert_local_image_to_data_url_async(message_piece.converted_value)
617617
image_url_entry = {"url": data_base64_encoded_url}
618618
entry = {"type": "image_url", "image_url": image_url_entry}
619619
content.append(entry)

pyrit/prompt_target/openai/openai_response_target.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414

1515
from openai.types.shared import ReasoningEffort
1616

17-
from pyrit.common.data_url_converter import convert_local_image_to_data_url
17+
from pyrit.common.data_url_converter import convert_local_image_to_data_url_async
1818
from pyrit.exceptions import (
1919
EmptyResponseException,
2020
PyritException,
@@ -239,7 +239,7 @@ async def _construct_input_item_from_piece(self, piece: MessagePiece) -> dict[st
239239
"text": piece.converted_value,
240240
}
241241
if piece.converted_value_data_type == "image_path":
242-
data_url = await convert_local_image_to_data_url(piece.converted_value)
242+
data_url = await convert_local_image_to_data_url_async(piece.converted_value)
243243
return {"type": "input_image", "image_url": {"url": data_url}}
244244
raise ValueError(f"Unsupported piece type for inline content: {piece.converted_value_data_type}")
245245

pyrit/prompt_target/websocket_copilot_target.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
from websockets.exceptions import InvalidStatus
1515

1616
from pyrit.auth import CopilotAuthenticator, ManualCopilotAuthenticator
17-
from pyrit.common.data_url_converter import convert_local_image_to_data_url
17+
from pyrit.common.data_url_converter import convert_local_image_to_data_url_async
1818
from pyrit.exceptions import (
1919
EmptyResponseException,
2020
pyrit_target_retry,
@@ -328,7 +328,7 @@ async def _process_image_piece_async(self, *, image_path: str, copilot_conversat
328328
Returns:
329329
dict: Message annotation structure for the uploaded image.
330330
"""
331-
data_url = await convert_local_image_to_data_url(image_path)
331+
data_url = await convert_local_image_to_data_url_async(image_path)
332332

333333
normalized_image_path = image_path.replace("\\", "/")
334334
file_name = pathlib.Path(normalized_image_path).name

tests/unit/common/test_convert_local_image_to_data_url.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,13 @@
77

88
import pytest
99

10-
from pyrit.common.data_url_converter import convert_local_image_to_data_url
10+
from pyrit.common.data_url_converter import convert_local_image_to_data_url_async
1111
from pyrit.memory.sqlite_memory import SQLiteMemory
1212

1313

1414
async def test_convert_image_to_data_url_file_not_found():
1515
with pytest.raises(FileNotFoundError):
16-
await convert_local_image_to_data_url("nonexistent.jpg")
16+
await convert_local_image_to_data_url_async("nonexistent.jpg")
1717

1818

1919
async def test_convert_image_with_unsupported_extension():
@@ -23,7 +23,7 @@ async def test_convert_image_with_unsupported_extension():
2323
assert os.path.exists(tmp_file_name)
2424

2525
with pytest.raises(ValueError) as exc_info:
26-
await convert_local_image_to_data_url(tmp_file_name)
26+
await convert_local_image_to_data_url_async(tmp_file_name)
2727

2828
assert "Unsupported image format" in str(exc_info.value)
2929

@@ -36,7 +36,7 @@ async def test_convert_local_image_to_data_url_unsupported_format():
3636
tmp_file_name = tmp_file.name
3737
try:
3838
with pytest.raises(ValueError) as excinfo:
39-
await convert_local_image_to_data_url(tmp_file_name)
39+
await convert_local_image_to_data_url_async(tmp_file_name)
4040
assert "Unsupported image format" in str(excinfo.value)
4141
finally:
4242
os.remove(tmp_file_name)
@@ -45,7 +45,7 @@ async def test_convert_local_image_to_data_url_unsupported_format():
4545
async def test_convert_local_image_to_data_url_missing_file():
4646
# Should raise FileNotFoundError for missing file
4747
with pytest.raises(FileNotFoundError):
48-
await convert_local_image_to_data_url("not_a_real_file.jpg")
48+
await convert_local_image_to_data_url_async("not_a_real_file.jpg")
4949

5050

5151
@patch("os.path.exists", return_value=True)
@@ -63,7 +63,7 @@ async def test_convert_image_to_data_url_success(
6363

6464
assert os.path.exists(tmp_file_name)
6565

66-
result = await convert_local_image_to_data_url(tmp_file_name)
66+
result = await convert_local_image_to_data_url_async(tmp_file_name)
6767
assert "data:image/jpeg;base64,encoded_base64_string" in result
6868

6969
# Assertions for the mocks

tests/unit/common/test_data_url_converter.py

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from pyrit.common.data_url_converter import (
1111
AZURE_OPENAI_GPT4O_SUPPORTED_IMAGE_FORMATS,
1212
convert_local_image_to_data_url,
13+
convert_local_image_to_data_url_async,
1314
)
1415

1516

@@ -21,15 +22,15 @@ def test_supported_image_formats_contains_common_types():
2122

2223
async def test_convert_raises_file_not_found():
2324
with pytest.raises(FileNotFoundError):
24-
await convert_local_image_to_data_url("nonexistent_image.jpg")
25+
await convert_local_image_to_data_url_async("nonexistent_image.jpg")
2526

2627

2728
async def test_convert_raises_for_unsupported_format():
2829
with NamedTemporaryFile(suffix=".svg", delete=False) as f:
2930
tmp = f.name
3031
try:
3132
with pytest.raises(ValueError, match="Unsupported image format"):
32-
await convert_local_image_to_data_url(tmp)
33+
await convert_local_image_to_data_url_async(tmp)
3334
finally:
3435
os.remove(tmp)
3536

@@ -42,7 +43,24 @@ async def test_convert_returns_data_url():
4243
mock_serializer.read_data_base64 = AsyncMock(return_value="AAAA")
4344

4445
with patch("pyrit.common.data_url_converter.data_serializer_factory", return_value=mock_serializer):
45-
result = await convert_local_image_to_data_url(tmp)
46+
result = await convert_local_image_to_data_url_async(tmp)
47+
48+
assert result.startswith("data:image/png;base64,")
49+
assert result.endswith("AAAA")
50+
finally:
51+
os.remove(tmp)
52+
53+
54+
async def test_deprecated_alias_emits_warning_and_delegates():
55+
with NamedTemporaryFile(suffix=".png", delete=False) as f:
56+
tmp = f.name
57+
try:
58+
mock_serializer = AsyncMock()
59+
mock_serializer.read_data_base64 = AsyncMock(return_value="AAAA")
60+
61+
with patch("pyrit.common.data_url_converter.data_serializer_factory", return_value=mock_serializer):
62+
with pytest.warns(DeprecationWarning, match="convert_local_image_to_data_url"):
63+
result = await convert_local_image_to_data_url(tmp)
4664

4765
assert result.startswith("data:image/png;base64,")
4866
assert result.endswith("AAAA")

0 commit comments

Comments
 (0)