diff --git a/pyrit/converter/binary_converter.py b/pyrit/converter/binary_converter.py index 73e5a1e133..96270e606e 100644 --- a/pyrit/converter/binary_converter.py +++ b/pyrit/converter/binary_converter.py @@ -6,6 +6,8 @@ from enum import Enum from typing import TYPE_CHECKING +# Deprecation support: remove in 1.4.0. +from pyrit.common.deprecation import print_deprecation_message from pyrit.converter.word_level_converter import WordLevelConverter if TYPE_CHECKING: @@ -64,9 +66,14 @@ def _build_identifier(self) -> ComponentIdentifier: } ) + # Deprecation shim: remove in 1.4.0 with both hooks; keep _validate_word. def validate_input(self, prompt: str) -> None: """ - Check if ``bits_per_char`` is sufficient for the characters in the prompt. + Validate the bit width of selected words (deprecated until 1.4.0). + + Subclass overrides may still call this method via ``super()``. After removal, + standalone preflight is caller-owned: inherited ``WordLevelConverter.validate_input`` + does not check bit width. Args: prompt (str): The input text prompt to validate. @@ -74,8 +81,34 @@ def validate_input(self, prompt: str) -> None: Raises: ValueError: If ``bits_per_char`` is too small to represent any character in the prompt. """ + print_deprecation_message( + old_item="BinaryConverter.validate_input", + new_item="BinaryConverter.convert_async", + removed_in="1.4.0", + ) + words = prompt.split() if self._word_split_separator is None else prompt.split(self._word_split_separator) + selected_indices = self._word_selection_strategy.select_words(words=words) + for idx in selected_indices: + self._validate_word(words[idx]) + + # Deprecation helper: remove in 1.4.0 with validate_input. + def _validate_before_conversion(self, prompt: str) -> None: + """Skip only the built-in deprecated validator, preserving subclass overrides.""" + if type(self).validate_input is not BinaryConverter.validate_input: + self.validate_input(prompt=prompt) + + def _validate_word(self, word: str) -> None: + """ + Check if ``bits_per_char`` is sufficient for the characters in a word being converted. + + Args: + word (str): The word that is about to be converted. + + Raises: + ValueError: If ``bits_per_char`` is too small to represent any character in the word. + """ bits = self.bits_per_char.value - max_code_point = max((ord(char) for char in prompt), default=0) + max_code_point = max((ord(char) for char in word), default=0) min_bits_required = max_code_point.bit_length() if bits < min_bits_required: raise ValueError( @@ -92,7 +125,12 @@ async def convert_word_async(self, word: str) -> str: Returns: str: The converted word. + + Raises: + ValueError: If ``bits_per_char`` is too small to represent any character in the word. """ + # Validate per word because unselected words are not encoded. + self._validate_word(word) bits = self.bits_per_char.value return " ".join(format(ord(char), f"0{bits}b") for char in word) diff --git a/pyrit/converter/word_level_converter.py b/pyrit/converter/word_level_converter.py index 8778ce7982..5cd7fc871c 100644 --- a/pyrit/converter/word_level_converter.py +++ b/pyrit/converter/word_level_converter.py @@ -78,6 +78,11 @@ async def convert_word_async(self, word: str) -> str: def validate_input(self, prompt: str) -> None: """Validate the input before processing (can be overridden by subclasses).""" + # Deprecation helper: remove in 1.4.0 with BinaryConverter's override. + def _validate_before_conversion(self, prompt: str) -> None: + """Delegate automatic validation to the existing subclass hook.""" + self.validate_input(prompt=prompt) + def join_words(self, words: list[str]) -> str: """ Provide a way for subclasses to override the default behavior of joining words. @@ -117,7 +122,8 @@ async def convert_async(self, *, prompt: str, input_type: PromptDataType = "text if input_type != "text": raise ValueError(f"Input type {input_type} not supported") - self.validate_input(prompt=prompt) + # Deprecation helper: restore self.validate_input(prompt=prompt) in 1.4.0. + self._validate_before_conversion(prompt=prompt) words = prompt.split() if self._word_split_separator is None else prompt.split(self._word_split_separator) diff --git a/tests/unit/converter/test_binary_converter.py b/tests/unit/converter/test_binary_converter.py index 41bfc3da14..0de685517f 100644 --- a/tests/unit/converter/test_binary_converter.py +++ b/tests/unit/converter/test_binary_converter.py @@ -1,9 +1,14 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT license. +# Deprecation test support: remove in 1.4.0. +import warnings +from contextlib import nullcontext + import pytest from pyrit.converter import BinaryConverter, ConverterResult +from pyrit.converter.text_selection_strategy import WordIndexSelectionStrategy async def test_binary_converter_8_bit_ascii(): @@ -38,3 +43,82 @@ async def test_binary_converter_32_bit_emoji(): async def test_binary_converter_invalid_bits_per_char(): with pytest.raises(TypeError, match="bits_per_char must be an instance of BinaryConverter.BitsPerChar Enum."): BinaryConverter(bits_per_char=10) # Invalid bits_per_char + + +async def test_binary_converter_raises_when_selected_word_exceeds_bits(): + converter = BinaryConverter(bits_per_char=BinaryConverter.BitsPerChar.BITS_16) + with pytest.raises(ValueError, match="bits_per_char=16 is too small"): + await converter.convert_async(prompt="hello 👋", input_type="text") + + +async def test_binary_converter_ignores_unselected_word_exceeding_bits(): + # Only "hello" is converted, so the emoji in the unselected word is passed + # through untouched and must not fail validation. + converter = BinaryConverter( + bits_per_char=BinaryConverter.BitsPerChar.BITS_16, + word_selection_strategy=WordIndexSelectionStrategy(indices=[0]), + ) + result = await converter.convert_async(prompt="hello 👋", input_type="text") + expected_hello = " ".join(format(ord(char), "016b") for char in "hello") + space_binary = format(ord(" "), "016b") + assert result.output_text == f"{expected_hello} {space_binary} 👋" + assert result.output_type == "text" + + +# Deprecation tests: remove in 1.4.0. +class TestBinaryConverterValidationDeprecation: + @pytest.mark.parametrize(("index", "raises"), [(0, False), (1, True)]) + def test_validate_input_warns_and_checks_selected_words(self, *, index: int, raises: bool) -> None: + converter = BinaryConverter(word_selection_strategy=WordIndexSelectionStrategy(indices=[index])) + with pytest.warns(DeprecationWarning) as recorded: + with pytest.raises(ValueError, match="Minimum required bits: 17") if raises else nullcontext(): + assert converter.validate_input("hello 👋") is None + assert len(recorded) == 1 + assert str(recorded[0].message) == ( + "BinaryConverter.validate_input is deprecated and will be removed in 1.4.0. " + "Use BinaryConverter.convert_async instead." + ) + assert recorded[0].filename == __file__ + + @pytest.mark.parametrize(("prompt", "index"), [("", 0), ("hello 👋", 0), ("hello 👋", 1)]) + async def test_builtin_validation_does_not_warn_async(self, *, prompt: str, index: int) -> None: + class PlainBinaryConverter(BinaryConverter): + pass + + for converter_type in (BinaryConverter, PlainBinaryConverter): + converter = converter_type(word_selection_strategy=WordIndexSelectionStrategy(indices=[index])) + with warnings.catch_warnings(): + warnings.simplefilter("error", DeprecationWarning) + with pytest.raises(ValueError, match="bits_per_char=16") if index == 1 else nullcontext(): + await converter.convert_async(prompt=prompt) + + @pytest.mark.parametrize("mode", ["accept", "reject", "super"]) + async def test_custom_validation_is_preserved_async(self, mode: str) -> None: + validated_prompts: list[str] = [] + + class CustomBinaryConverter(BinaryConverter): + def validate_input(self, prompt: str) -> None: + validated_prompts.append(prompt) + if mode == "reject": + raise ValueError("Rejected by custom validation") + if mode == "super": + super().validate_input(prompt) + + class InheritedCustomBinaryConverter(CustomBinaryConverter): + pass + + for converter_type in (CustomBinaryConverter, InheritedCustomBinaryConverter): + validated_prompts.clear() + converter = converter_type(word_selection_strategy=WordIndexSelectionStrategy(indices=[0])) + with warnings.catch_warnings(record=True) as recorded: + warnings.simplefilter("always", DeprecationWarning) + with ( + pytest.raises(ValueError, match="Rejected by custom validation") + if mode == "reject" + else nullcontext() + ): + result = await converter.convert_async(prompt="hello 👋") + assert validated_prompts == ["hello 👋"] + assert [warning.category for warning in recorded] == ([DeprecationWarning] if mode == "super" else []) + if mode != "reject": + assert result.output_text.endswith("👋") diff --git a/tests/unit/converter/test_word_level_converter.py b/tests/unit/converter/test_word_level_converter.py index a7c59c97e9..6411ae2e99 100644 --- a/tests/unit/converter/test_word_level_converter.py +++ b/tests/unit/converter/test_word_level_converter.py @@ -160,3 +160,26 @@ async def test_custom_separator_preserved_with_partial_selection(self): ) result = await converter.convert_async(prompt="alpha,beta,gamma") assert result.output_text == "ALPHA,beta,GAMMA" + + +@pytest.mark.parametrize("prompt", ["", "hello world"]) +@pytest.mark.parametrize("reject", [False, True]) +async def test_convert_async_calls_subclass_validation_async(*, prompt: str, reject: bool) -> None: + validated_prompts: list[str] = [] + + class ValidatingWordLevelConverter(SimpleWordLevelConverter): + def validate_input(self, prompt: str) -> None: + validated_prompts.append(prompt) + if reject: + raise ValueError("Rejected by subclass validation") + + converter = ValidatingWordLevelConverter( + word_selection_strategy=WordIndexSelectionStrategy(indices=[10] if reject else [0]) + ) + if reject: + with pytest.raises(ValueError, match="Rejected by subclass validation"): + await converter.convert_async(prompt=prompt) + else: + result = await converter.convert_async(prompt=prompt) + assert result.output_text == ("" if not prompt else "HELLO world") + assert validated_prompts == [prompt]