diff --git a/src/ape/managers/converters.py b/src/ape/managers/converters.py index 05b3b544d3..c2773ccae3 100644 --- a/src/ape/managers/converters.py +++ b/src/ape/managers/converters.py @@ -1,3 +1,4 @@ +import re from datetime import datetime, timedelta, timezone from decimal import Decimal from typing import Any, Dict, List, Sequence, Tuple, Type, Union @@ -185,6 +186,22 @@ def convert(self, value: Union[str, datetime, timedelta]) -> int: raise ConversionError() +class StringDecimalConverter(ConverterAPI): + """ + Convert string-formatted floating point values to `Decimal` type. + """ + + def is_convertible(self, value: Any) -> bool: + # Matches only string-formatted floats with an optional sign character (+/-). + # Leading and trailing zeros are required. + # NOTE: `re.fullmatch` will only match the full string, so "1.0 ether" and "10.0 USDC" + # will not be identified as convertible. + return isinstance(value, str) and re.fullmatch(r"[+-]?\d+\.\d+", value) is not None + + def convert(self, value: str) -> Decimal: + return Decimal(value) + + class ConversionManager(BaseManager): """ A singleton that manages all the converters. @@ -219,7 +236,7 @@ def _converters(self) -> Dict[Type, List[ConverterAPI]]: StringIntConverter(), AccountIntConverter(), ], - Decimal: [], + Decimal: [StringDecimalConverter()], bool: [], str: [], } diff --git a/src/ape_ethereum/ecosystem.py b/src/ape_ethereum/ecosystem.py index fbcd8a3c0c..f1ba47ef64 100644 --- a/src/ape_ethereum/ecosystem.py +++ b/src/ape_ethereum/ecosystem.py @@ -1,5 +1,6 @@ import re from copy import deepcopy +from decimal import Decimal from functools import cached_property from typing import Any, ClassVar, Dict, Iterator, List, Optional, Sequence, Tuple, Type, Union, cast @@ -582,6 +583,9 @@ def _python_type_for_abi_type(self, abi_type: ABIType) -> Union[Type, Sequence]: elif "int" in abi_type.type: return int + elif "fixed" in abi_type.type: + return Decimal + raise ConversionError(f"Unable to convert '{abi_type}'.") def encode_calldata(self, abi: Union[ConstructorABI, MethodABI], *args) -> HexBytes: diff --git a/tests/functional/conversion/test_decimal.py b/tests/functional/conversion/test_decimal.py new file mode 100644 index 0000000000..d783080c4d --- /dev/null +++ b/tests/functional/conversion/test_decimal.py @@ -0,0 +1,83 @@ +from decimal import Decimal + +import pytest + +from ape.exceptions import ConversionError + + +def test_convert_formatted_float_strings_to_decimal(convert): + test_strings = [ + "1.000", + "1.00", + "1.0", + "0.1", + "0.01", + "0.001", + ] + for test_string in test_strings: + actual = convert(test_string, Decimal) + assert actual == Decimal(test_string) + + +def test_convert_badly_formatted_float_strings_to_decimal(convert): + test_strings = [ + ".1", + "1.", + ".", + ] + for test_string in test_strings: + with pytest.raises( + ConversionError, match=f"No conversion registered to handle '{test_string}'" + ): + convert(test_string, Decimal) + + +def test_convert_int_strings(convert): + test_strings = [ + "1", + "10", + "100", + ] + for test_string in test_strings: + with pytest.raises( + ConversionError, match=f"No conversion registered to handle '{test_string}'" + ): + convert(test_string, Decimal) + + +def test_convert_alphanumeric_strings(convert): + test_strings = [ + "a", + "H", + "XYZ", + ] + for test_string in test_strings: + with pytest.raises( + ConversionError, match=f"No conversion registered to handle '{test_string}'" + ): + convert(test_string, Decimal) + + +def test_convert_strings_with_token_names(convert): + test_strings = [ + "0.999 DAI", + "10.0 USDC", + ] + for test_string in test_strings: + with pytest.raises( + ConversionError, match=f"No conversion registered to handle '{test_string}'" + ): + convert(test_string, Decimal) + + +def test_convert_strings_with_ether_alias(convert): + test_strings = [ + "0 wei", + "999 gwei", + "1.0 ether", + ] + for test_string in test_strings: + with pytest.raises( + ConversionError, match=f"No conversion registered to handle '{test_string}'" + ): + convert(test_string, Decimal)