Skip to content
Open
42 changes: 40 additions & 2 deletions pyrit/converter/binary_converter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -64,18 +66,49 @@ 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.

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(
Expand All @@ -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)

Expand Down
8 changes: 7 additions & 1 deletion pyrit/converter/word_level_converter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)

Expand Down
84 changes: 84 additions & 0 deletions tests/unit/converter/test_binary_converter.py
Original file line number Diff line number Diff line change
@@ -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():
Expand Down Expand Up @@ -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("👋")
23 changes: 23 additions & 0 deletions tests/unit/converter/test_word_level_converter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]