From adb5cf955edcd2f207987948fce0e32677eb7a8d Mon Sep 17 00:00:00 2001 From: Richard Lundeen Date: Fri, 12 Jun 2026 15:55:00 -0700 Subject: [PATCH 1/2] MAINT: Phase 18 - relocate two pyrit.common reverse-guard offenders Relocate the data-URL and question-answering helpers out of pyrit.common so the import-boundary reverse guard can be enforced without exceptions: - pyrit.common.data_url_converter -> pyrit.memory.storage.data_url_converter - pyrit.common.question_answer_helpers -> pyrit.score.question_answer_helpers Each old path keeps a one-release deprecation re-export shim (module_deprecation_getattr, removed_in 0.16.0) with a note to update KNOWN_COMMON_VIOLATIONS in test_import_boundary.py on removal. Internal callers and test patch targets are repointed, and the two relocated entries are dropped from KNOWN_COMMON_VIOLATIONS. display_response is left in place (slated for deletion) and keeps its violation entry. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pyrit/common/__init__.py | 6 +- pyrit/common/data_url_converter.py | 91 +++++++------------ pyrit/common/question_answer_helpers.py | 40 +++++--- pyrit/memory/storage/__init__.py | 6 ++ pyrit/memory/storage/data_url_converter.py | 60 ++++++++++++ .../chat_message_normalizer.py | 4 +- .../openai/openai_chat_target.py | 10 +- .../openai/openai_response_target.py | 10 +- .../prompt_target/websocket_copilot_target.py | 11 ++- pyrit/score/question_answer_helpers.py | 18 ++++ .../test_convert_local_image_to_data_url.py | 75 --------------- tests/unit/common/test_deprecation_shims.py | 75 +++++++++++++++ .../storage}/test_data_url_converter.py | 6 +- tests/unit/models/test_import_boundary.py | 6 -- .../target/test_openai_chat_target.py | 8 +- .../target/test_openai_response_target.py | 10 +- .../test_question_answer_helpers.py | 2 +- 17 files changed, 266 insertions(+), 172 deletions(-) create mode 100644 pyrit/memory/storage/data_url_converter.py create mode 100644 pyrit/score/question_answer_helpers.py delete mode 100644 tests/unit/common/test_convert_local_image_to_data_url.py create mode 100644 tests/unit/common/test_deprecation_shims.py rename tests/unit/{common => memory/storage}/test_data_url_converter.py (87%) rename tests/unit/{common => score}/test_question_answer_helpers.py (95%) diff --git a/pyrit/common/__init__.py b/pyrit/common/__init__.py index 44b4c381a4..e3891460a0 100644 --- a/pyrit/common/__init__.py +++ b/pyrit/common/__init__.py @@ -4,9 +4,9 @@ """ Common utilities and helpers for PyRIT. -Heavy submodules (data_url_converter, display_response, download_hf_model, -net_utility) are intentionally NOT re-exported here to keep ``import pyrit`` -fast. Import them directly, e.g.:: +Heavy submodules (display_response, download_hf_model, net_utility) are +intentionally NOT re-exported here to keep ``import pyrit`` fast. Import them +directly, e.g.:: from pyrit.common.net_utility import get_httpx_client """ diff --git a/pyrit/common/data_url_converter.py b/pyrit/common/data_url_converter.py index 6fef6337d6..1e7b7ff420 100644 --- a/pyrit/common/data_url_converter.py +++ b/pyrit/common/data_url_converter.py @@ -1,60 +1,37 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT license. -from pyrit.common.deprecation import print_deprecation_message -from pyrit.memory import DataTypeSerializer, data_serializer_factory - -# Supported image formats for Azure OpenAI GPT-4o, -# https://learn.microsoft.com/en-us/azure/ai-services/openai/concepts/use-your-image-data -AZURE_OPENAI_GPT4O_SUPPORTED_IMAGE_FORMATS = [".jpg", ".jpeg", ".png", ".gif", ".bmp", ".tiff", ".tif"] - - -async def convert_local_image_to_data_url_async(image_path: str) -> str: - """ - Convert a local image file to a data URL encoded in base64. - - Args: - image_path (str): The file system path to the image file. - - Returns: - str: A string containing the MIME type and the base64-encoded data of the image, formatted as a data URL. - - Raises: - FileNotFoundError: If no file is found at the specified `image_path`. - ValueError: If the image file's extension is not in the supported formats list. - """ - ext = DataTypeSerializer.get_extension(image_path) - if ext is None or ext.lower() not in AZURE_OPENAI_GPT4O_SUPPORTED_IMAGE_FORMATS: - raise ValueError( - f"Unsupported image format: {ext}. Supported formats are: {AZURE_OPENAI_GPT4O_SUPPORTED_IMAGE_FORMATS}" - ) - - mime_type = DataTypeSerializer.get_mime_type(image_path) - if not mime_type: - mime_type = "application/octet-stream" - - image_serializer = data_serializer_factory( - category="prompt-memory-entries", value=image_path, data_type="image_path", extension=ext - ) - base64_encoded_data = await image_serializer.read_data_base64_async() - # Azure OpenAI documentation doesn't specify the local image upload format for API. - # GPT-4o image upload format is determined using "view code" functionality in Azure OpenAI deployments - # The image upload format is same as GPT-4 Turbo. - # Construct the data URL, as per Azure OpenAI GPT-4 Turbo local image format - # 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 - return f"data:{mime_type};base64,{base64_encoded_data}" - - -async def convert_local_image_to_data_url(image_path: str) -> str: # pyrit-async-suffix-exempt - """ - Delegate to ``convert_local_image_to_data_url_async`` (deprecated alias). - - Returns: - str: A string containing the MIME type and the base64-encoded data of the image, formatted as a data URL. - """ - print_deprecation_message( - old_item="pyrit.common.data_url_converter.convert_local_image_to_data_url", - new_item="pyrit.common.data_url_converter.convert_local_image_to_data_url_async", - removed_in="0.16.0", - ) - return await convert_local_image_to_data_url_async(image_path) +""" +Deprecation shim — the data-URL conversion helpers now live in +``pyrit.memory.storage``. + +Importing names from ``pyrit.common.data_url_converter`` still works for one +release but emits a one-time ``DeprecationWarning`` per name. Import from +``pyrit.memory.storage`` instead. This shim will be removed in 0.16.0. + +NOTE: When this shim is removed, also drop the ``pyrit.common.data_url_converter`` +entry from ``KNOWN_COMMON_VIOLATIONS`` in +``tests/unit/models/test_import_boundary.py`` if it has not already been removed, +so the reverse-guard ratchet bookkeeping is not missed. +""" + +from __future__ import annotations + +from pyrit.common.deprecation import module_deprecation_getattr + +__all__ = [ + "AZURE_OPENAI_GPT4O_SUPPORTED_IMAGE_FORMATS", + "convert_local_image_to_data_url", + "convert_local_image_to_data_url_async", +] + +__getattr__ = module_deprecation_getattr( + old_module="pyrit.common.data_url_converter", + target_module="pyrit.memory.storage.data_url_converter", + names=__all__, + removed_in="0.16.0", +) + + +def __dir__() -> list[str]: + return sorted(__all__) diff --git a/pyrit/common/question_answer_helpers.py b/pyrit/common/question_answer_helpers.py index 4ffa71411a..69157f87e6 100644 --- a/pyrit/common/question_answer_helpers.py +++ b/pyrit/common/question_answer_helpers.py @@ -1,18 +1,36 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT license. -from pyrit.models import QuestionAnsweringEntry +""" +Deprecation shim — the question-answering scoring helpers now live in +``pyrit.score``. +Importing names from ``pyrit.common.question_answer_helpers`` still works for one +release but emits a one-time ``DeprecationWarning`` per name. Import from +``pyrit.score.question_answer_helpers`` instead. This shim will be removed in +0.16.0. -def construct_evaluation_prompt(entry: QuestionAnsweringEntry) -> str: - """ - From question and choices in entry, creates prompt to be send to target. +NOTE: When this shim is removed, also drop the +``pyrit.common.question_answer_helpers`` entry from ``KNOWN_COMMON_VIOLATIONS`` in +``tests/unit/models/test_import_boundary.py`` if it has not already been removed, +so the reverse-guard ratchet bookkeeping is not missed. +""" - Args: - entry (QuestionAnsweringEntry): A single entry from which the prompt is constructed. +from __future__ import annotations - Returns: - str: The constructed prompt string. - """ - available_choices = "\n".join(f"index={c.index}, value={c.text}" for c in entry.choices) - return f"Question:\n{entry.question}\n\nChoices:\n{available_choices}" +from pyrit.common.deprecation import module_deprecation_getattr + +__all__ = [ + "construct_evaluation_prompt", +] + +__getattr__ = module_deprecation_getattr( + old_module="pyrit.common.question_answer_helpers", + target_module="pyrit.score.question_answer_helpers", + names=__all__, + removed_in="0.16.0", +) + + +def __dir__() -> list[str]: + return sorted(__all__) diff --git a/pyrit/memory/storage/__init__.py b/pyrit/memory/storage/__init__.py index b10fcb1d35..cb978f6d11 100644 --- a/pyrit/memory/storage/__init__.py +++ b/pyrit/memory/storage/__init__.py @@ -15,6 +15,10 @@ holds the blob payloads those records point to. """ +from pyrit.memory.storage.data_url_converter import ( + convert_local_image_to_data_url, + convert_local_image_to_data_url_async, +) from pyrit.memory.storage.serializers import ( AllowedCategories, AudioPathDataTypeSerializer, @@ -41,6 +45,8 @@ "AudioPathDataTypeSerializer", "AzureBlobStorageIO", "BinaryPathDataTypeSerializer", + "convert_local_image_to_data_url", + "convert_local_image_to_data_url_async", "DataTypeSerializer", "data_serializer_factory", "DiskStorageIO", diff --git a/pyrit/memory/storage/data_url_converter.py b/pyrit/memory/storage/data_url_converter.py new file mode 100644 index 0000000000..e802266b82 --- /dev/null +++ b/pyrit/memory/storage/data_url_converter.py @@ -0,0 +1,60 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +from pyrit.common.deprecation import print_deprecation_message +from pyrit.memory.storage.serializers import DataTypeSerializer, data_serializer_factory + +# Supported image formats for Azure OpenAI GPT-4o, +# https://learn.microsoft.com/en-us/azure/ai-services/openai/concepts/use-your-image-data +AZURE_OPENAI_GPT4O_SUPPORTED_IMAGE_FORMATS = [".jpg", ".jpeg", ".png", ".gif", ".bmp", ".tiff", ".tif"] + + +async def convert_local_image_to_data_url_async(image_path: str) -> str: + """ + Convert a local image file to a data URL encoded in base64. + + Args: + image_path (str): The file system path to the image file. + + Returns: + str: A string containing the MIME type and the base64-encoded data of the image, formatted as a data URL. + + Raises: + FileNotFoundError: If no file is found at the specified `image_path`. + ValueError: If the image file's extension is not in the supported formats list. + """ + ext = DataTypeSerializer.get_extension(image_path) + if ext is None or ext.lower() not in AZURE_OPENAI_GPT4O_SUPPORTED_IMAGE_FORMATS: + raise ValueError( + f"Unsupported image format: {ext}. Supported formats are: {AZURE_OPENAI_GPT4O_SUPPORTED_IMAGE_FORMATS}" + ) + + mime_type = DataTypeSerializer.get_mime_type(image_path) + if not mime_type: + mime_type = "application/octet-stream" + + image_serializer = data_serializer_factory( + category="prompt-memory-entries", value=image_path, data_type="image_path", extension=ext + ) + base64_encoded_data = await image_serializer.read_data_base64_async() + # Azure OpenAI documentation doesn't specify the local image upload format for API. + # GPT-4o image upload format is determined using "view code" functionality in Azure OpenAI deployments + # The image upload format is same as GPT-4 Turbo. + # Construct the data URL, as per Azure OpenAI GPT-4 Turbo local image format + # 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 + return f"data:{mime_type};base64,{base64_encoded_data}" + + +async def convert_local_image_to_data_url(image_path: str) -> str: # pyrit-async-suffix-exempt + """ + Delegate to ``convert_local_image_to_data_url_async`` (deprecated alias). + + Returns: + str: A string containing the MIME type and the base64-encoded data of the image, formatted as a data URL. + """ + print_deprecation_message( + old_item="pyrit.memory.storage.data_url_converter.convert_local_image_to_data_url", + new_item="pyrit.memory.storage.data_url_converter.convert_local_image_to_data_url_async", + removed_in="0.16.0", + ) + return await convert_local_image_to_data_url_async(image_path) diff --git a/pyrit/message_normalizer/chat_message_normalizer.py b/pyrit/message_normalizer/chat_message_normalizer.py index 08be67bacd..2d187c2737 100644 --- a/pyrit/message_normalizer/chat_message_normalizer.py +++ b/pyrit/message_normalizer/chat_message_normalizer.py @@ -8,8 +8,10 @@ import aiofiles -from pyrit.common.data_url_converter import convert_local_image_to_data_url_async from pyrit.memory import DataTypeSerializer +from pyrit.memory.storage.data_url_converter import ( + convert_local_image_to_data_url_async, +) from pyrit.message_normalizer.message_normalizer import ( MessageListNormalizer, MessageStringNormalizer, diff --git a/pyrit/prompt_target/openai/openai_chat_target.py b/pyrit/prompt_target/openai/openai_chat_target.py index 9f6b672f2b..b302f66e79 100644 --- a/pyrit/prompt_target/openai/openai_chat_target.py +++ b/pyrit/prompt_target/openai/openai_chat_target.py @@ -7,13 +7,15 @@ from collections.abc import MutableSequence from typing import Any -from pyrit.common.data_url_converter import convert_local_image_to_data_url_async from pyrit.exceptions import ( EmptyResponseException, PyritException, pyrit_target_retry, ) from pyrit.memory import DataTypeSerializer, data_serializer_factory +from pyrit.memory.storage.data_url_converter import ( + convert_local_image_to_data_url_async, +) from pyrit.models import ( ChatMessage, ComponentIdentifier, @@ -24,7 +26,11 @@ from pyrit.prompt_target.common.json_response_config import _JsonResponseConfig from pyrit.prompt_target.common.target_capabilities import TargetCapabilities from pyrit.prompt_target.common.target_configuration import TargetConfiguration -from pyrit.prompt_target.common.utils import limit_requests_per_minute, validate_temperature, validate_top_p +from pyrit.prompt_target.common.utils import ( + limit_requests_per_minute, + validate_temperature, + validate_top_p, +) from pyrit.prompt_target.openai.openai_chat_audio_config import OpenAIChatAudioConfig from pyrit.prompt_target.openai.openai_target import OpenAITarget diff --git a/pyrit/prompt_target/openai/openai_response_target.py b/pyrit/prompt_target/openai/openai_response_target.py index 8717846cdc..8ff4970f4b 100644 --- a/pyrit/prompt_target/openai/openai_response_target.py +++ b/pyrit/prompt_target/openai/openai_response_target.py @@ -13,12 +13,14 @@ from openai.types.shared import ReasoningEffort -from pyrit.common.data_url_converter import convert_local_image_to_data_url_async from pyrit.exceptions import ( EmptyResponseException, PyritException, pyrit_target_retry, ) +from pyrit.memory.storage.data_url_converter import ( + convert_local_image_to_data_url_async, +) from pyrit.models import ( ComponentIdentifier, Message, @@ -29,7 +31,11 @@ from pyrit.prompt_target.common.json_response_config import _JsonResponseConfig from pyrit.prompt_target.common.target_capabilities import TargetCapabilities from pyrit.prompt_target.common.target_configuration import TargetConfiguration -from pyrit.prompt_target.common.utils import limit_requests_per_minute, validate_temperature, validate_top_p +from pyrit.prompt_target.common.utils import ( + limit_requests_per_minute, + validate_temperature, + validate_top_p, +) from pyrit.prompt_target.openai.openai_error_handling import _is_content_filter_error from pyrit.prompt_target.openai.openai_target import OpenAITarget diff --git a/pyrit/prompt_target/websocket_copilot_target.py b/pyrit/prompt_target/websocket_copilot_target.py index d9450ff590..7abb96209e 100644 --- a/pyrit/prompt_target/websocket_copilot_target.py +++ b/pyrit/prompt_target/websocket_copilot_target.py @@ -14,13 +14,20 @@ from websockets.exceptions import InvalidStatus from pyrit.auth import CopilotAuthenticator, ManualCopilotAuthenticator -from pyrit.common.data_url_converter import convert_local_image_to_data_url_async from pyrit.exceptions import ( EmptyResponseException, pyrit_target_retry, ) from pyrit.memory import DataTypeSerializer -from pyrit.models import ComponentIdentifier, Message, MessagePiece, construct_response_from_request +from pyrit.memory.storage.data_url_converter import ( + convert_local_image_to_data_url_async, +) +from pyrit.models import ( + ComponentIdentifier, + Message, + MessagePiece, + construct_response_from_request, +) from pyrit.prompt_target import PromptTarget, limit_requests_per_minute from pyrit.prompt_target.common.target_capabilities import TargetCapabilities from pyrit.prompt_target.common.target_configuration import TargetConfiguration diff --git a/pyrit/score/question_answer_helpers.py b/pyrit/score/question_answer_helpers.py new file mode 100644 index 0000000000..4ffa71411a --- /dev/null +++ b/pyrit/score/question_answer_helpers.py @@ -0,0 +1,18 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +from pyrit.models import QuestionAnsweringEntry + + +def construct_evaluation_prompt(entry: QuestionAnsweringEntry) -> str: + """ + From question and choices in entry, creates prompt to be send to target. + + Args: + entry (QuestionAnsweringEntry): A single entry from which the prompt is constructed. + + Returns: + str: The constructed prompt string. + """ + available_choices = "\n".join(f"index={c.index}, value={c.text}" for c in entry.choices) + return f"Question:\n{entry.question}\n\nChoices:\n{available_choices}" diff --git a/tests/unit/common/test_convert_local_image_to_data_url.py b/tests/unit/common/test_convert_local_image_to_data_url.py deleted file mode 100644 index 502cc1df95..0000000000 --- a/tests/unit/common/test_convert_local_image_to_data_url.py +++ /dev/null @@ -1,75 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT license. - -import os -from tempfile import NamedTemporaryFile -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest - -from pyrit.common.data_url_converter import convert_local_image_to_data_url_async -from pyrit.memory.sqlite_memory import SQLiteMemory - - -async def test_convert_image_to_data_url_file_not_found(): - with pytest.raises(FileNotFoundError): - await convert_local_image_to_data_url_async("nonexistent.jpg") - - -async def test_convert_image_with_unsupported_extension(): - with NamedTemporaryFile(mode="w+", suffix=".txt", delete=False) as tmp_file: - tmp_file_name = tmp_file.name - - assert os.path.exists(tmp_file_name) - - with pytest.raises(ValueError) as exc_info: - await convert_local_image_to_data_url_async(tmp_file_name) - - assert "Unsupported image format" in str(exc_info.value) - - os.remove(tmp_file_name) - - -async def test_convert_local_image_to_data_url_unsupported_format(): - # Should raise ValueError for unsupported extension - with NamedTemporaryFile(suffix=".webp", delete=False) as tmp_file: - tmp_file_name = tmp_file.name - try: - with pytest.raises(ValueError) as excinfo: - await convert_local_image_to_data_url_async(tmp_file_name) - assert "Unsupported image format" in str(excinfo.value) - finally: - os.remove(tmp_file_name) - - -async def test_convert_local_image_to_data_url_missing_file(): - # Should raise FileNotFoundError for missing file - with pytest.raises(FileNotFoundError): - await convert_local_image_to_data_url_async("not_a_real_file.jpg") - - -@patch("os.path.exists", return_value=True) -@patch("mimetypes.guess_type", return_value=("image/jpg", None)) -@patch("pyrit.memory.storage.serializers.ImagePathDataTypeSerializer") -@patch("pyrit.memory.CentralMemory.get_memory_instance", return_value=SQLiteMemory(db_path=":memory:")) -async def test_convert_image_to_data_url_success( - mock_get_memory_instance, mock_serializer_class, mock_guess_type, mock_exists -): - with NamedTemporaryFile(suffix=".jpg", delete=False) as tmp_file: - tmp_file_name = tmp_file.name - mock_serializer_instance = MagicMock() - mock_serializer_instance.read_data_base64_async = AsyncMock(return_value="encoded_base64_string") - mock_serializer_class.return_value = mock_serializer_instance - - assert os.path.exists(tmp_file_name) - - result = await convert_local_image_to_data_url_async(tmp_file_name) - assert "data:image/jpeg;base64,encoded_base64_string" in result - - # Assertions for the mocks - mock_serializer_class.assert_called_once_with( - category="prompt-memory-entries", prompt_text=tmp_file_name, extension=".jpg" - ) - mock_serializer_instance.read_data_base64_async.assert_called_once() - - os.remove(tmp_file_name) diff --git a/tests/unit/common/test_deprecation_shims.py b/tests/unit/common/test_deprecation_shims.py new file mode 100644 index 0000000000..7fec3d5738 --- /dev/null +++ b/tests/unit/common/test_deprecation_shims.py @@ -0,0 +1,75 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +""" +Tests for the Phase 18 ``pyrit.common`` reverse-guard relocation shims. + +``pyrit.common.data_url_converter`` moved to ``pyrit.memory.storage`` and +``pyrit.common.question_answer_helpers`` moved to ``pyrit.score``. The old module +paths still forward to the new locations but emit a ``DeprecationWarning`` per +name. These tests pin that contract. The shims will be removed in 0.16.0. +""" + +from __future__ import annotations + +import importlib +import warnings + +import pytest + +import pyrit.common.data_url_converter as data_url_shim +import pyrit.common.question_answer_helpers as question_answer_shim +import pyrit.memory.storage.data_url_converter as new_data_url +import pyrit.score.question_answer_helpers as new_question_answer + +MODULE_SHIM_PAIRS = [ + ( + data_url_shim, + new_data_url, + "pyrit.common.data_url_converter", + "pyrit.memory.storage.data_url_converter", + ), + ( + question_answer_shim, + new_question_answer, + "pyrit.common.question_answer_helpers", + "pyrit.score.question_answer_helpers", + ), +] + + +@pytest.mark.parametrize("shim_mod, new_mod, old_path, new_path", MODULE_SHIM_PAIRS) +def test_module_shim_forwards_every_name(shim_mod, new_mod, old_path, new_path): + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + for name in shim_mod.__all__: + assert getattr(shim_mod, name) is getattr(new_mod, name), f"{old_path}.{name} did not forward" + + +@pytest.mark.parametrize("shim_mod, new_mod, old_path, new_path", MODULE_SHIM_PAIRS) +def test_module_shim_warns_once_per_name(shim_mod, new_mod, old_path, new_path): + # Reload the shim to reset its internal warn-once closure for a clean count. + shim_mod = importlib.reload(shim_mod) + for name in shim_mod.__all__: + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always", DeprecationWarning) + getattr(shim_mod, name) + getattr(shim_mod, name) + + dep = [w for w in caught if issubclass(w.category, DeprecationWarning)] + assert len(dep) == 1, f"Expected 1 DeprecationWarning for {old_path}.{name}, got {len(dep)}" + message = str(dep[0].message) + assert f"{old_path}.{name}" in message + assert f"{new_path}.{name}" in message + assert "0.16.0" in message + + +@pytest.mark.parametrize("shim_mod, new_mod, old_path, new_path", MODULE_SHIM_PAIRS) +def test_module_shim_attribute_error_for_unknown_name(shim_mod, new_mod, old_path, new_path): + with pytest.raises(AttributeError, match=f"module {old_path!r} has no attribute"): + _ = shim_mod.definitely_not_a_real_name + + +@pytest.mark.parametrize("shim_mod, new_mod, old_path, new_path", MODULE_SHIM_PAIRS) +def test_module_shim_dir_returns_sorted_all(shim_mod, new_mod, old_path, new_path): + assert dir(shim_mod) == sorted(shim_mod.__all__) diff --git a/tests/unit/common/test_data_url_converter.py b/tests/unit/memory/storage/test_data_url_converter.py similarity index 87% rename from tests/unit/common/test_data_url_converter.py rename to tests/unit/memory/storage/test_data_url_converter.py index 229c72b6f0..333092b5b1 100644 --- a/tests/unit/common/test_data_url_converter.py +++ b/tests/unit/memory/storage/test_data_url_converter.py @@ -7,7 +7,7 @@ import pytest -from pyrit.common.data_url_converter import ( +from pyrit.memory.storage.data_url_converter import ( AZURE_OPENAI_GPT4O_SUPPORTED_IMAGE_FORMATS, convert_local_image_to_data_url, convert_local_image_to_data_url_async, @@ -42,7 +42,7 @@ async def test_convert_returns_data_url(): mock_serializer = AsyncMock() mock_serializer.read_data_base64_async = AsyncMock(return_value="AAAA") - with patch("pyrit.common.data_url_converter.data_serializer_factory", return_value=mock_serializer): + with patch("pyrit.memory.storage.data_url_converter.data_serializer_factory", return_value=mock_serializer): result = await convert_local_image_to_data_url_async(tmp) assert result.startswith("data:image/png;base64,") @@ -58,7 +58,7 @@ async def test_deprecated_alias_emits_warning_and_delegates(): mock_serializer = AsyncMock() mock_serializer.read_data_base64_async = AsyncMock(return_value="AAAA") - with patch("pyrit.common.data_url_converter.data_serializer_factory", return_value=mock_serializer): + with patch("pyrit.memory.storage.data_url_converter.data_serializer_factory", return_value=mock_serializer): with pytest.warns(DeprecationWarning, match="convert_local_image_to_data_url"): result = await convert_local_image_to_data_url(tmp) diff --git a/tests/unit/models/test_import_boundary.py b/tests/unit/models/test_import_boundary.py index bcb52c1cd6..8a61023a5e 100644 --- a/tests/unit/models/test_import_boundary.py +++ b/tests/unit/models/test_import_boundary.py @@ -64,16 +64,10 @@ # Reverse-guard violations: pyrit.common modules that still reach up into higher # layers. These are slated to relocate; the ratchet forces them to shrink. KNOWN_COMMON_VIOLATIONS: dict[str, dict[str, str]] = { - "pyrit.common.data_url_converter": { - "pyrit.memory": "relocate", - }, "pyrit.common.display_response": { "pyrit.memory": "relocate", "pyrit.models": "relocate", }, - "pyrit.common.question_answer_helpers": { - "pyrit.models": "relocate", - }, } diff --git a/tests/unit/prompt_target/target/test_openai_chat_target.py b/tests/unit/prompt_target/target/test_openai_chat_target.py index 25d3664f31..205b5270d7 100644 --- a/tests/unit/prompt_target/target/test_openai_chat_target.py +++ b/tests/unit/prompt_target/target/test_openai_chat_target.py @@ -122,7 +122,7 @@ async def test_build_chat_messages_for_multi_modal(target: OpenAIChatTarget): ) ] with patch( - "pyrit.common.data_url_converter.convert_local_image_to_data_url_async", + "pyrit.memory.storage.data_url_converter.convert_local_image_to_data_url_async", return_value="data:image/jpeg;base64,encoded_string", ): messages = await target._build_chat_messages_for_multi_modal_async(entries) @@ -298,7 +298,7 @@ async def test_send_prompt_async_empty_response_adds_to_memory(openai_response_j ) # Make assistant response empty with patch( - "pyrit.common.data_url_converter.convert_local_image_to_data_url_async", + "pyrit.memory.storage.data_url_converter.convert_local_image_to_data_url_async", return_value="data:image/jpeg;base64,encoded_string", ): # Mock the OpenAI SDK client to return empty content @@ -403,7 +403,7 @@ async def test_send_prompt_async(openai_response_json: dict, patch_central_datab ] ) with patch( - "pyrit.common.data_url_converter.convert_local_image_to_data_url_async", + "pyrit.memory.storage.data_url_converter.convert_local_image_to_data_url_async", return_value="data:image/jpeg;base64,encoded_string", ): # Mock the OpenAI SDK client to return a completion @@ -465,7 +465,7 @@ async def test_send_prompt_async_empty_response_retries(openai_response_json: di ) # Make assistant response empty with patch( - "pyrit.common.data_url_converter.convert_local_image_to_data_url_async", + "pyrit.memory.storage.data_url_converter.convert_local_image_to_data_url_async", return_value="data:image/jpeg;base64,encoded_string", ): # Mock the OpenAI SDK client to return empty content diff --git a/tests/unit/prompt_target/target/test_openai_response_target.py b/tests/unit/prompt_target/target/test_openai_response_target.py index 67baff3de8..ddad0ab30c 100644 --- a/tests/unit/prompt_target/target/test_openai_response_target.py +++ b/tests/unit/prompt_target/target/test_openai_response_target.py @@ -173,7 +173,7 @@ async def test_build_input_for_multi_modal(target: OpenAIResponseTarget): ), ] with patch( - "pyrit.common.data_url_converter.convert_local_image_to_data_url_async", + "pyrit.memory.storage.data_url_converter.convert_local_image_to_data_url_async", return_value="data:image/jpeg;base64,encoded_string", ): messages = await target._build_input_for_multi_modal_async(entries) @@ -324,7 +324,7 @@ async def test_send_prompt_async_empty_response_adds_to_memory( mock_response = create_mock_response(openai_response_json) with patch( - "pyrit.common.data_url_converter.convert_local_image_to_data_url_async", + "pyrit.memory.storage.data_url_converter.convert_local_image_to_data_url_async", return_value="data:image/jpeg;base64,encoded_string", ): target._async_client.responses.create = AsyncMock(return_value=mock_response) # type: ignore[method-assign] @@ -411,7 +411,7 @@ async def test_send_prompt_async(openai_response_json: dict, target: OpenAIRespo mock_response = create_mock_response(openai_response_json) with patch( - "pyrit.common.data_url_converter.convert_local_image_to_data_url_async", + "pyrit.memory.storage.data_url_converter.convert_local_image_to_data_url_async", return_value="data:image/jpeg;base64,encoded_string", ): target._async_client.responses.create = AsyncMock(return_value=mock_response) # type: ignore[method-assign] @@ -455,7 +455,7 @@ async def test_send_prompt_async_empty_response_retries(openai_response_json: di mock_response = create_mock_response(openai_response_json) with patch( - "pyrit.common.data_url_converter.convert_local_image_to_data_url_async", + "pyrit.memory.storage.data_url_converter.convert_local_image_to_data_url_async", return_value="data:image/jpeg;base64,encoded_string", ): target._async_client.responses.create = AsyncMock(return_value=mock_response) # type: ignore[method-assign] @@ -740,7 +740,7 @@ async def test_build_input_for_multi_modal_async_filters_reasoning(target: OpenA ] # Patch image conversion (should not be called) - with patch("pyrit.common.data_url_converter.convert_local_image_to_data_url_async", new_callable=AsyncMock): + with patch("pyrit.memory.storage.data_url_converter.convert_local_image_to_data_url_async", new_callable=AsyncMock): result = await target._build_input_for_multi_modal_async(conversation) # Reasoning is now filtered out (not sent to API), so we have 3 items: diff --git a/tests/unit/common/test_question_answer_helpers.py b/tests/unit/score/test_question_answer_helpers.py similarity index 95% rename from tests/unit/common/test_question_answer_helpers.py rename to tests/unit/score/test_question_answer_helpers.py index eac5beca14..048be30393 100644 --- a/tests/unit/common/test_question_answer_helpers.py +++ b/tests/unit/score/test_question_answer_helpers.py @@ -1,8 +1,8 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT license. -from pyrit.common.question_answer_helpers import construct_evaluation_prompt from pyrit.models import QuestionAnsweringEntry, QuestionChoice +from pyrit.score.question_answer_helpers import construct_evaluation_prompt def test_construct_evaluation_prompt_basic(): From 9fa02c7d3b41851b6065b13faef90115f267f7c1 Mon Sep 17 00:00:00 2001 From: Richard Lundeen Date: Fri, 12 Jun 2026 16:03:52 -0700 Subject: [PATCH 2/2] Use package-root import for relocated data_url_converter symbol Per the style guide, import convert_local_image_to_data_url_async from the pyrit.memory.storage package root (where it is re-exported) rather than the full submodule path. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pyrit/message_normalizer/chat_message_normalizer.py | 4 +--- pyrit/prompt_target/openai/openai_chat_target.py | 4 +--- pyrit/prompt_target/openai/openai_response_target.py | 4 +--- pyrit/prompt_target/websocket_copilot_target.py | 4 +--- 4 files changed, 4 insertions(+), 12 deletions(-) diff --git a/pyrit/message_normalizer/chat_message_normalizer.py b/pyrit/message_normalizer/chat_message_normalizer.py index 2d187c2737..ab7b2e2410 100644 --- a/pyrit/message_normalizer/chat_message_normalizer.py +++ b/pyrit/message_normalizer/chat_message_normalizer.py @@ -9,9 +9,7 @@ import aiofiles from pyrit.memory import DataTypeSerializer -from pyrit.memory.storage.data_url_converter import ( - convert_local_image_to_data_url_async, -) +from pyrit.memory.storage import convert_local_image_to_data_url_async from pyrit.message_normalizer.message_normalizer import ( MessageListNormalizer, MessageStringNormalizer, diff --git a/pyrit/prompt_target/openai/openai_chat_target.py b/pyrit/prompt_target/openai/openai_chat_target.py index b302f66e79..9374ea73b8 100644 --- a/pyrit/prompt_target/openai/openai_chat_target.py +++ b/pyrit/prompt_target/openai/openai_chat_target.py @@ -13,9 +13,7 @@ pyrit_target_retry, ) from pyrit.memory import DataTypeSerializer, data_serializer_factory -from pyrit.memory.storage.data_url_converter import ( - convert_local_image_to_data_url_async, -) +from pyrit.memory.storage import convert_local_image_to_data_url_async from pyrit.models import ( ChatMessage, ComponentIdentifier, diff --git a/pyrit/prompt_target/openai/openai_response_target.py b/pyrit/prompt_target/openai/openai_response_target.py index 8ff4970f4b..b0b72a5c5f 100644 --- a/pyrit/prompt_target/openai/openai_response_target.py +++ b/pyrit/prompt_target/openai/openai_response_target.py @@ -18,9 +18,7 @@ PyritException, pyrit_target_retry, ) -from pyrit.memory.storage.data_url_converter import ( - convert_local_image_to_data_url_async, -) +from pyrit.memory.storage import convert_local_image_to_data_url_async from pyrit.models import ( ComponentIdentifier, Message, diff --git a/pyrit/prompt_target/websocket_copilot_target.py b/pyrit/prompt_target/websocket_copilot_target.py index 7abb96209e..87b8d69b03 100644 --- a/pyrit/prompt_target/websocket_copilot_target.py +++ b/pyrit/prompt_target/websocket_copilot_target.py @@ -19,9 +19,7 @@ pyrit_target_retry, ) from pyrit.memory import DataTypeSerializer -from pyrit.memory.storage.data_url_converter import ( - convert_local_image_to_data_url_async, -) +from pyrit.memory.storage import convert_local_image_to_data_url_async from pyrit.models import ( ComponentIdentifier, Message,