From 9b9e4a015810a3882a277352229a05986c8fc276 Mon Sep 17 00:00:00 2001 From: Juliya Smith Date: Tue, 10 Oct 2023 23:49:51 -0500 Subject: [PATCH 01/10] feat: hexstr --- README.md | 19 +++++- eth_pydantic_types/__init__.py | 14 +++- eth_pydantic_types/_error.py | 18 ++++++ eth_pydantic_types/address.py | 26 +++++--- eth_pydantic_types/hash.py | 78 +++++++++-------------- eth_pydantic_types/hex.py | 104 ++++++++++++++++++++++++++++++ eth_pydantic_types/hexbytes.py | 32 ---------- eth_pydantic_types/validators.py | 5 +- tests/test_address.py | 16 +++-- tests/test_hash.py | 5 +- tests/test_hex.py | 106 +++++++++++++++++++++++++++++++ tests/test_hexbytes.py | 47 -------------- 12 files changed, 321 insertions(+), 149 deletions(-) create mode 100644 eth_pydantic_types/_error.py create mode 100644 eth_pydantic_types/hex.py delete mode 100644 eth_pydantic_types/hexbytes.py create mode 100644 tests/test_hex.py delete mode 100644 tests/test_hexbytes.py diff --git a/README.md b/README.md index 0c65591..bbb7118 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ The types in this package are pydantic types for Ethereum inspired from [eth-typ ## Hash32 Hash32 is a good type to use for Ethereum transaction hashes. -When using the Hash32 type, your inputs will be validated by length and your schema will declare the input a string with a binary format. +`Hash` types serialize to bytes in the Pydantic core schema and `string` in the JSON schema with a binary format. Use Hash32 like this: ```python @@ -29,6 +29,7 @@ tx = Transaction( A thin-wrapper around an already thin-wrapper `hexbytes.HexBytes`. The difference here is that this HexBytes properly serializes. Use HexBytes any place where you would actually use `hexbytes.HexBytes`. +`HexBytes` serializes to bytes in the Pydantic core schema and `string` in the JSON schema with a binary format. ```python from pydantic import BaseModel @@ -45,6 +46,7 @@ storage = MyStorage(cid="0x123") Use the Address class for working with checksummed-addresses. Addresses get validated and checksummed in model construction. +Addresses serialize to `str` in the Pydantic core schema and `string` in the JSON schema with a binary format. ```python from pydantic import BaseModel @@ -57,3 +59,18 @@ class Account(BaseModel): # ("0x0837207e343277CBd6c114a45EC0e9Ec56a1AD84") account = Account(address="0x837207e343277cbd6c114a45ec0e9ec56a1ad84") ``` + +## HexStr + +Use hex str when you only care about un-sized hex strings. +The `HexStr` type serializes to `str` in the Pydantic core schema and a `string` in the JSON schema with a binary format. + +```python +from eth_pydantic_types import HexStr +from pydantic import BaseModel + +class Tx(BaseModel): + data: HexStr + +tx = Tx(data="0x0123") +``` diff --git a/eth_pydantic_types/__init__.py b/eth_pydantic_types/__init__.py index f0a8028..ade4345 100644 --- a/eth_pydantic_types/__init__.py +++ b/eth_pydantic_types/__init__.py @@ -1,5 +1,15 @@ from .address import Address from .hash import Hash4, Hash8, Hash16, Hash20, Hash32, Hash64 -from .hexbytes import HexBytes +from .hex import HexBytes, HexStr -__all__ = ["Address", "Hash4", "Hash8", "Hash16", "Hash20", "Hash32", "Hash64", "HexBytes"] +__all__ = [ + "Address", + "Hash4", + "Hash8", + "Hash16", + "Hash20", + "Hash32", + "Hash64", + "HexBytes", + "HexStr", +] diff --git a/eth_pydantic_types/_error.py b/eth_pydantic_types/_error.py new file mode 100644 index 0000000..a9366c1 --- /dev/null +++ b/eth_pydantic_types/_error.py @@ -0,0 +1,18 @@ +from typing import Any, Callable + +from pydantic_core import PydanticCustomError + +# NOTE: We use the factory approach because PydanticCustomError is a final class. +# That is also why this module is internal. + + +def EthPydanticTypesException(fn: Callable, invalid_tag: str, **kwargs): + return PydanticCustomError(fn.__name__, f"Invalid {invalid_tag}", kwargs) + + +def HexValueError(value: Any): + return EthPydanticTypesException(HexValueError, "hex value", value=value) + + +def SizeError(size: Any, value: Any): + return EthPydanticTypesException(SizeError, "size of value", value=value) diff --git a/eth_pydantic_types/address.py b/eth_pydantic_types/address.py index 0e96ae9..5da018d 100644 --- a/eth_pydantic_types/address.py +++ b/eth_pydantic_types/address.py @@ -1,4 +1,4 @@ -from typing import Any, Optional +from typing import Any, ClassVar, Optional, Tuple from eth_utils import is_checksum_address, to_checksum_address from pydantic_core import CoreSchema @@ -8,20 +8,32 @@ with_info_before_validator_function, ) -from eth_pydantic_types.hexbytes import HexBytes +from eth_pydantic_types.hex import BaseHexStr, HexBytes from eth_pydantic_types.validators import validate_address_size +ADDRESS_PATTERN = "^0x[a-fA-F0-9]{40}$" -class Address(str): + +def address_schema(): + return str_schema(min_length=42, max_length=42, pattern=ADDRESS_PATTERN) + + +class Address(BaseHexStr): """ Use for address-types. Validates as a checksummed address. Left-pads zeroes if necessary. """ + schema_pattern: ClassVar[str] = ADDRESS_PATTERN + schema_examples: ClassVar[Tuple[str, ...]] = ( + "0x0000000000000000000000000000000000000000", # empty address + "0x1e59ce931B4CFea3fe4B875411e280e173cB7A9C", + ) + def __get_pydantic_core_schema__(self, *args, **kwargs) -> CoreSchema: schema = with_info_before_validator_function( self._validate_address, - str_schema(min_length=42, max_length=42, pattern="^0x[a-fA-F0-9]{40}$"), + address_schema(), ) return schema @@ -37,9 +49,3 @@ def _validate_address(cls, value: Any, info: Optional[ValidationInfo] = None) -> number_padded = validate_address_size(number, 40) value = f"0x{number_padded}" return to_checksum_address(value) - - def __int__(self) -> int: - return int(self, 16) - - def __bytes__(self) -> HexBytes: - return HexBytes(self) diff --git a/eth_pydantic_types/hash.py b/eth_pydantic_types/hash.py index 0a3079b..ae8fa9c 100644 --- a/eth_pydantic_types/hash.py +++ b/eth_pydantic_types/hash.py @@ -1,4 +1,4 @@ -from typing import Any, ClassVar, Optional +from typing import Any, ClassVar, Optional, Tuple from pydantic_core.core_schema import ( CoreSchema, @@ -7,11 +7,19 @@ with_info_before_validator_function, ) -from eth_pydantic_types.hexbytes import HexBytes +from eth_pydantic_types.hex import HexBytes from eth_pydantic_types.serializers import hex_serializer from eth_pydantic_types.validators import validate_bytes_size +def get_hash_pattern(size: int) -> str: + return f"^0x[a-fA-F0-9]{{{size}}}$" + + +def get_hash_examples(size: int) -> Tuple[str, str]: + return f"0x{'0' * (size * 2)}", f"0x{'1e' * size}" + + class Hash(HexBytes): """ Represents a single-slot static hash. @@ -20,6 +28,8 @@ class Hash(HexBytes): """ size: ClassVar[int] = 1 + schema_pattern: ClassVar[str] = get_hash_pattern(1) + schema_examples: ClassVar[Tuple[str, ...]] = get_hash_examples(1) def __get_pydantic_core_schema__(self, *args, **kwargs) -> CoreSchema: schema = with_info_before_validator_function( @@ -37,49 +47,21 @@ def validate_size(cls, value: bytes) -> bytes: return validate_bytes_size(value, cls.size) -class Hash4(Hash): - """ - A hash that is 4-bytes. - """ - - size: ClassVar[int] = 4 - - -class Hash8(Hash): - """ - A hash that is 8-bytes. - """ - - size: ClassVar[int] = 8 - - -class Hash16(Hash): - """ - A hash that is 16-bytes. - """ - - size: ClassVar[int] = 16 - - -class Hash20(Hash): - """ - A hash that is 20-bytes. - """ - - size: ClassVar[int] = 20 - - -class Hash32(Hash): - """ - A hash that is 32-bytes. - """ - - size: ClassVar[int] = 32 - - -class Hash64(Hash): - """ - A hash that is 64-bytes. - """ - - size: ClassVar[int] = 64 +def make_hash_cls(size: int): + return type( + f"Hash{size}", + (Hash,), + dict( + size=size, + schema_pattern=get_hash_pattern(size), + schema_examples=get_hash_examples(size), + ), + ) + + +Hash4 = make_hash_cls(4) +Hash8 = make_hash_cls(8) +Hash16 = make_hash_cls(16) +Hash20 = make_hash_cls(20) +Hash32 = make_hash_cls(32) +Hash64 = make_hash_cls(64) diff --git a/eth_pydantic_types/hex.py b/eth_pydantic_types/hex.py new file mode 100644 index 0000000..519095a --- /dev/null +++ b/eth_pydantic_types/hex.py @@ -0,0 +1,104 @@ +from typing import Any, ClassVar, Optional, Tuple, Union + +from hexbytes import HexBytes as BaseHexBytes +from pydantic_core import CoreSchema +from pydantic_core.core_schema import ( + ValidationInfo, + bytes_schema, + no_info_before_validator_function, + str_schema, + with_info_before_validator_function, +) + +from eth_pydantic_types._error import HexValueError +from eth_pydantic_types.serializers import hex_serializer + +schema_pattern = "^0x([0-9a-f][0-9a-f])*$" +schema_examples = ( + "0x", # empty bytes + "0xd4", + "0xd4e5", + "0xd4e56740", + "0xd4e56740f876aef8", + "0xd4e56740f876aef8c010b86a40d5f567", + "0xd4e56740f876aef8c010b86a40d5f56745a118d0906a34e69aec8c0db1cb8fa3", +) + + +class BaseHex: + schema_pattern: ClassVar[str] = schema_pattern + schema_examples: ClassVar[Tuple[str, ...]] = schema_examples + + @classmethod + def __get_pydantic_json_schema__(cls, core_schema, handler): + json_schema = handler(core_schema) + json_schema.update( + format="binary", pattern=cls.schema_pattern, examples=list(cls.schema_examples) + ) + return json_schema + + +class HexBytes(BaseHexBytes, BaseHex): + """ + Use when receiving ``hexbytes.HexBytes`` values. Includes + a pydantic validator and serializer. + """ + + def __get_pydantic_core_schema__(self, *args, **kwargs) -> CoreSchema: + schema = with_info_before_validator_function(self._validate_hexbytes, bytes_schema()) + schema["serialization"] = hex_serializer + return schema + + @classmethod + def fromhex(cls, hex_str: str) -> "HexBytes": + value = hex_str[2:] if hex_str.startswith("0x") else hex_str + return super().fromhex(value) + + @classmethod + def _validate_hexbytes(cls, value: Any, info: Optional[ValidationInfo] = None) -> BaseHexBytes: + return BaseHexBytes(value) + + +class BaseHexStr(str, BaseHex): + @classmethod + def from_bytes(cls, data: bytes) -> "BaseHexStr": + hex_str = data.hex() + return cls(hex_str if hex_str.startswith("0x") else hex_str) + + def __int__(self) -> int: + return int(self, 16) + + def __bytes__(self) -> bytes: + return bytes.fromhex(self[2:]) + + +class HexStr(BaseHexStr): + """A hex string value, typically from a hash.""" + + def __get_pydantic_core_schema__(cls, *args, **kwargs): + return no_info_before_validator_function(cls.validate_hex, str_schema()) + + @classmethod + def validate_hex(cls, data: Union[bytes, str, int]): + if isinstance(data, bytes): + return cls.from_bytes(data) + + elif isinstance(data, str): + return cls._validate_hex_str(data) + + elif isinstance(data, int): + return BaseHexBytes(data).hex() + + raise HexValueError(data) + + @classmethod + def _validate_hex_str(cls, data: str) -> str: + hex_value = (data[2:] if data.startswith("0x") else data).lower() + if set(hex_value) - set("1234567890abcdef"): + raise HexValueError(data) + + # Missing zero padding. + if len(hex_value) % 2 != 0: + hex_value = f"0{hex_value}" + + return f"0x{hex_value}" diff --git a/eth_pydantic_types/hexbytes.py b/eth_pydantic_types/hexbytes.py deleted file mode 100644 index b7b6622..0000000 --- a/eth_pydantic_types/hexbytes.py +++ /dev/null @@ -1,32 +0,0 @@ -from typing import Any, Optional - -from hexbytes import HexBytes as BaseHexBytes -from pydantic_core import CoreSchema -from pydantic_core.core_schema import ( - ValidationInfo, - bytes_schema, - with_info_before_validator_function, -) - -from eth_pydantic_types.serializers import hex_serializer - - -class HexBytes(BaseHexBytes): - """ - Use when receiving ``hexbytes.HexBytes`` values. Includes - a pydantic validator and serializer. - """ - - def __get_pydantic_core_schema__(self, *args, **kwargs) -> CoreSchema: - schema = with_info_before_validator_function(self._validate_hexbytes, bytes_schema()) - schema["serialization"] = hex_serializer - return schema - - @classmethod - def fromhex(cls, hex_str: str) -> "HexBytes": - value = hex_str[2:] if hex_str.startswith("0x") else hex_str - return super().fromhex(value) - - @classmethod - def _validate_hexbytes(cls, value: Any, info: Optional[ValidationInfo] = None) -> BaseHexBytes: - return BaseHexBytes(value) diff --git a/eth_pydantic_types/validators.py b/eth_pydantic_types/validators.py index b1ae6a9..ce07cca 100644 --- a/eth_pydantic_types/validators.py +++ b/eth_pydantic_types/validators.py @@ -1,9 +1,10 @@ from typing import Any, Callable, Dict, Optional, Sized, TypeVar, cast from pydantic import WithJsonSchema -from pydantic_core import PydanticCustomError from pydantic_core.core_schema import bytes_schema +from eth_pydantic_types._error import SizeError + __SIZED_T = TypeVar("__SIZED_T", bound=Sized) @@ -21,7 +22,7 @@ def validate_size(value: __SIZED_T, size: int, coerce: Optional[Callable] = None elif coerce: return validate_size(coerce(value), size) - raise PydanticCustomError("value_size", "Invalid size of value", {"size": size, "value": value}) + raise SizeError(size, value) def validate_bytes_size(value: bytes, size: int) -> bytes: diff --git a/tests/test_address.py b/tests/test_address.py index c375e15..7799c5e 100644 --- a/tests/test_address.py +++ b/tests/test_address.py @@ -2,7 +2,7 @@ from pydantic import BaseModel, ValidationError from eth_pydantic_types.address import Address -from eth_pydantic_types.hexbytes import HexBytes +from eth_pydantic_types.hex import HexBytes # NOTE: This address purposely is the wrong length (missing left zero), # not checksummed, and not 0x prefixed. @@ -43,11 +43,15 @@ def test_invalid_address(address): def test_schema(): actual = Model.model_json_schema() - for name, prop in actual["properties"].items(): - assert prop["maxLength"] == 42 - assert prop["minLength"] == 42 - assert prop["type"] == "string" - assert prop["pattern"] == "^0x[a-fA-F0-9]{40}$" + prop = actual["properties"]["address"] + assert prop["maxLength"] == 42 + assert prop["minLength"] == 42 + assert prop["type"] == "string" + assert prop["pattern"] == "^0x[a-fA-F0-9]{40}$" + assert prop["examples"] == [ + "0x0000000000000000000000000000000000000000", + "0x1e59ce931B4CFea3fe4B875411e280e173cB7A9C", + ] def test_model_dump(): diff --git a/tests/test_hash.py b/tests/test_hash.py index e397e25..c82ecf6 100644 --- a/tests/test_hash.py +++ b/tests/test_hash.py @@ -2,7 +2,7 @@ from pydantic import BaseModel, ValidationError from eth_pydantic_types.hash import Hash8, Hash16, Hash32, Hash64 -from eth_pydantic_types.hexbytes import HexBytes +from eth_pydantic_types.hex import HexBytes class Model(BaseModel): @@ -50,6 +50,9 @@ def test_schema(): assert prop["minLength"] == size_from_name assert prop["type"] == "string" assert prop["format"] == "binary" + assert prop["pattern"] == f"^0x[a-fA-F0-9]{{{size_from_name}}}$" + assert prop["examples"][0] == f"0x{'0' * size_from_name * 2}" + assert len(prop["examples"][1]) == size_from_name * 2 + 2 def test_model_dump(bytes32str): diff --git a/tests/test_hex.py b/tests/test_hex.py new file mode 100644 index 0000000..2e1a6c1 --- /dev/null +++ b/tests/test_hex.py @@ -0,0 +1,106 @@ +import pytest +from hexbytes import HexBytes as BaseHexBytes +from pydantic import BaseModel, ValidationError + +from eth_pydantic_types.hex import HexBytes, HexStr + + +class BytesModel(BaseModel): + value: HexBytes + + +class StrModel(BaseModel): + value: HexStr + + +@pytest.mark.parametrize("value", ("0xa", 10, b"\n")) +def test_hexbytes(value): + actual = BytesModel(value=value) + + # The end result, the value is a hexbytes.HexBytes + assert actual.value == BaseHexBytes(value) + assert actual.value.hex() == "0x0a" + assert isinstance(actual.value, bytes) + assert isinstance(actual.value, BaseHexBytes) + + +def test_invalid_hexbytes(): + with pytest.raises(ValidationError): + BytesModel(value="foo") + + +def test_hexbytes_fromhex(bytes32str): + actual_with_0x = HexBytes.fromhex(bytes32str) + actual_without_0x = HexBytes.fromhex(bytes32str[2:]) + expected = HexBytes(bytes32str) + assert actual_with_0x == actual_without_0x == expected + + +def test_hexbytes_schema(): + actual = BytesModel.model_json_schema() + prop = actual["properties"]["value"] + assert prop["type"] == "string" + assert prop["format"] == "binary" + assert prop["pattern"] == "^0x([0-9a-f][0-9a-f])*$" + assert prop["examples"] == [ + "0x", + "0xd4", + "0xd4e5", + "0xd4e56740", + "0xd4e56740f876aef8", + "0xd4e56740f876aef8c010b86a40d5f567", + "0xd4e56740f876aef8c010b86a40d5f56745a118d0906a34e69aec8c0db1cb8fa3", + ] + + +def test_hexbytes_model_dump(bytes32str): + model = BytesModel(value=bytes32str) + actual = model.model_dump() + expected = {"value": "0x9b70bd98ccb5b6434c2ead14d68d15f392435a06ff469f8d1f8cf38b2ae0b0e2"} + assert actual == expected + + +@pytest.mark.parametrize("value", ("0xa", 10, HexBytes(10))) +def test_hexstr(value): + actual = StrModel(value=value) + + # The end result, the value is a str + assert actual.value == "0x0a" + assert isinstance(actual.value, str) + + +def test_invalid_hexstr(): + with pytest.raises(ValidationError): + StrModel(value="foo") + + +def test_hexstr_conversions(): + model = StrModel(value="0x123") + assert int(model.value, 16) == 291 + assert bytes.fromhex(model.value[2:]) == b"\x01#" + + +def test_hexstr_schema(): + actual = StrModel.model_json_schema() + properties = actual["properties"] + assert len(properties) == 1 + prop = properties["value"] + assert prop["type"] == "string" + assert prop["format"] == "binary" + assert prop["pattern"] == "^0x([0-9a-f][0-9a-f])*$" + assert prop["examples"] == [ + "0x", + "0xd4", + "0xd4e5", + "0xd4e56740", + "0xd4e56740f876aef8", + "0xd4e56740f876aef8c010b86a40d5f567", + "0xd4e56740f876aef8c010b86a40d5f56745a118d0906a34e69aec8c0db1cb8fa3", + ] + + +def test_hexstr_model_dump(bytes32str): + model = StrModel(value=bytes32str) + actual = model.model_dump() + expected = {"value": "0x9b70bd98ccb5b6434c2ead14d68d15f392435a06ff469f8d1f8cf38b2ae0b0e2"} + assert actual == expected diff --git a/tests/test_hexbytes.py b/tests/test_hexbytes.py deleted file mode 100644 index f514e4b..0000000 --- a/tests/test_hexbytes.py +++ /dev/null @@ -1,47 +0,0 @@ -import pytest -from hexbytes import HexBytes as BaseHexBytes -from pydantic import BaseModel, ValidationError - -from eth_pydantic_types.hexbytes import HexBytes - - -class Model(BaseModel): - value: HexBytes - """The user declares their value to be our annotated type.""" - - -@pytest.mark.parametrize("value", ("0xa", 10, b"\n")) -def test_hexbytes(value): - actual = Model(value=value) - - # The end result, the value is a hexbytes.HexBytes - assert actual.value == BaseHexBytes(value) - assert actual.value.hex() == "0x0a" - assert isinstance(actual.value, bytes) - assert isinstance(actual.value, BaseHexBytes) - - -def test_invalid_hexbytes(): - with pytest.raises(ValidationError): - Model(value="foo") - - -def test_fromhex(bytes32str): - actual_with_0x = HexBytes.fromhex(bytes32str) - actual_without_0x = HexBytes.fromhex(bytes32str[2:]) - expected = HexBytes(bytes32str) - assert actual_with_0x == actual_without_0x == expected - - -def test_schema(): - actual = Model.model_json_schema() - for name, prop in actual["properties"].items(): - assert prop["type"] == "string" - assert prop["format"] == "binary" - - -def test_model_dump(bytes32str): - model = Model(value=bytes32str) - actual = model.model_dump() - expected = {"value": "0x9b70bd98ccb5b6434c2ead14d68d15f392435a06ff469f8d1f8cf38b2ae0b0e2"} - assert actual == expected From 28994c076da7463d845c9932b6957f6a170920d4 Mon Sep 17 00:00:00 2001 From: Juliya Smith Date: Wed, 11 Oct 2023 09:54:35 -0500 Subject: [PATCH 02/10] chore: docs and permissions --- .github/workflows/docs.yaml | 2 ++ .github/workflows/draft.yaml | 2 ++ docs/methoddocs/{hexbytes.md => hex.md} | 0 3 files changed, 4 insertions(+) rename docs/methoddocs/{hexbytes.md => hex.md} (100%) diff --git a/.github/workflows/docs.yaml b/.github/workflows/docs.yaml index 480f8ca..d6256ac 100644 --- a/.github/workflows/docs.yaml +++ b/.github/workflows/docs.yaml @@ -11,6 +11,8 @@ on: jobs: docs: runs-on: ubuntu-latest + permissions: + contents: write steps: - uses: actions/checkout@v3 diff --git a/.github/workflows/draft.yaml b/.github/workflows/draft.yaml index 423582b..dbe260e 100644 --- a/.github/workflows/draft.yaml +++ b/.github/workflows/draft.yaml @@ -8,6 +8,8 @@ on: jobs: update-draft: runs-on: ubuntu-latest + permissions: + contents: write steps: # Drafts your next Release notes as Pull Requests are merged into "main" - uses: release-drafter/release-drafter@v5 diff --git a/docs/methoddocs/hexbytes.md b/docs/methoddocs/hex.md similarity index 100% rename from docs/methoddocs/hexbytes.md rename to docs/methoddocs/hex.md From 1365a0dad12510c40826f09e798ee675f32059a8 Mon Sep 17 00:00:00 2001 From: Juliya Smith Date: Wed, 11 Oct 2023 10:40:13 -0500 Subject: [PATCH 03/10] feat: init bip122 --- eth_pydantic_types/__init__.py | 2 ++ eth_pydantic_types/_error.py | 19 +++++++++++----- eth_pydantic_types/bip122.py | 40 ++++++++++++++++++++++++++++++++++ eth_pydantic_types/hex.py | 20 ++++++++--------- 4 files changed, 66 insertions(+), 15 deletions(-) create mode 100644 eth_pydantic_types/bip122.py diff --git a/eth_pydantic_types/__init__.py b/eth_pydantic_types/__init__.py index ade4345..f367625 100644 --- a/eth_pydantic_types/__init__.py +++ b/eth_pydantic_types/__init__.py @@ -1,9 +1,11 @@ from .address import Address from .hash import Hash4, Hash8, Hash16, Hash20, Hash32, Hash64 from .hex import HexBytes, HexStr +from .bip122 import Bip122Uri __all__ = [ "Address", + "Bip122Uri", "Hash4", "Hash8", "Hash16", diff --git a/eth_pydantic_types/_error.py b/eth_pydantic_types/_error.py index a9366c1..90642bb 100644 --- a/eth_pydantic_types/_error.py +++ b/eth_pydantic_types/_error.py @@ -6,13 +6,22 @@ # That is also why this module is internal. -def EthPydanticTypesException(fn: Callable, invalid_tag: str, **kwargs): +def CustomError(fn: Callable, invalid_tag: str, **kwargs) -> PydanticCustomError: return PydanticCustomError(fn.__name__, f"Invalid {invalid_tag}", kwargs) -def HexValueError(value: Any): - return EthPydanticTypesException(HexValueError, "hex value", value=value) +def HexValueError(value: Any) -> PydanticCustomError: + return CustomError(HexValueError, "hex value", value=value) -def SizeError(size: Any, value: Any): - return EthPydanticTypesException(SizeError, "size of value", value=value) +def SizeError(size: Any, value: Any) -> PydanticCustomError: + return CustomError(SizeError, "size of value", size=size, value=value) + + +def Bip122UriFormatError(value: str) -> PydanticCustomError: + return CustomError( + Bip122UriFormatError, + "BIP-122 URI format", + uri=value, + format="blockchain:///block/." + ) diff --git a/eth_pydantic_types/bip122.py b/eth_pydantic_types/bip122.py new file mode 100644 index 0000000..b1db3a5 --- /dev/null +++ b/eth_pydantic_types/bip122.py @@ -0,0 +1,40 @@ +from typing import Any, Optional + +from pydantic_core import CoreSchema, PydanticCustomError +from pydantic_core.core_schema import ( + str_schema, + with_info_before_validator_function, + ValidationInfo, +) + +from eth_pydantic_types.hex import validate_hex_str +from eth_pydantic_types._error import Bip122UriFormatError + + +class Bip122Uri(str): + def __get_pydantic_core_schema__(self, *args, **kwargs) -> CoreSchema: + schema = with_info_before_validator_function( + self._validate, + str_schema(), + ) + return schema + + @classmethod + def _validate(cls, value: Any, info: Optional[ValidationInfo] = None) -> str: + prefix = "blockchain://" + if not value.startswith(prefix): + raise Bip122UriFormatError(value) + + protocol_suffix = value.replace(prefix, "") + protocol_parsed = protocol_suffix.split("/") + if len(protocol_parsed) != 3: + raise Bip122UriFormatError(value) + + genesis_hash, block_keyword, block_hash = protocol_parsed + + if block_keyword != "block": + raise Bip122UriFormatError(value) + + validated_genesis_hash = validate_hex_str(genesis_hash) + validated_block_hash = validate_hex_str(block_hash) + return f"{prefix}{validated_genesis_hash}/{block_keyword}/{validated_block_hash}" diff --git a/eth_pydantic_types/hex.py b/eth_pydantic_types/hex.py index 519095a..47ea196 100644 --- a/eth_pydantic_types/hex.py +++ b/eth_pydantic_types/hex.py @@ -84,21 +84,21 @@ def validate_hex(cls, data: Union[bytes, str, int]): return cls.from_bytes(data) elif isinstance(data, str): - return cls._validate_hex_str(data) + return validate_hex_str(data) elif isinstance(data, int): return BaseHexBytes(data).hex() raise HexValueError(data) - @classmethod - def _validate_hex_str(cls, data: str) -> str: - hex_value = (data[2:] if data.startswith("0x") else data).lower() - if set(hex_value) - set("1234567890abcdef"): - raise HexValueError(data) - # Missing zero padding. - if len(hex_value) % 2 != 0: - hex_value = f"0{hex_value}" +def validate_hex_str(value: str) -> str: + hex_value = (value[2:] if value.startswith("0x") else value).lower() + if set(hex_value) - set("1234567890abcdef"): + raise HexValueError(value) + + # Missing zero padding. + if len(hex_value) % 2 != 0: + hex_value = f"0{hex_value}" - return f"0x{hex_value}" + return f"0x{hex_value}" From 2aae8cffbb866301ab6a5a6dc97489bd5935b585 Mon Sep 17 00:00:00 2001 From: Juliya Smith Date: Wed, 11 Oct 2023 11:00:05 -0500 Subject: [PATCH 04/10] feat: bip122 --- README.md | 20 +++++++++++++++++ eth_pydantic_types/__init__.py | 2 +- eth_pydantic_types/_error.py | 2 +- eth_pydantic_types/bip122.py | 21 +++++++++++++----- tests/test_bip122.py | 40 ++++++++++++++++++++++++++++++++++ 5 files changed, 78 insertions(+), 7 deletions(-) create mode 100644 tests/test_bip122.py diff --git a/README.md b/README.md index bbb7118..4e4ea7e 100644 --- a/README.md +++ b/README.md @@ -74,3 +74,23 @@ class Tx(BaseModel): tx = Tx(data="0x0123") ``` + +## Bip122Uri + +Use BIP-122 URIs in your models by annotating with the `Bip122Uri` type. +This type serializes to a `str` in the Pydantic core schema as well as a `string` in the JSON schema, however the individual hashes are validated. + +```python +from eth_pydantic_types import Bip122Uri +from pydantic import BaseModel + +class Message(BaseModel): + path: Bip122Uri + +message = Message( + path=( + "blockchain://d4e56740f876aef8c010b86a40d5f56745a118d0906a34e69aec8c0db1cb8fa3" + "/block/752820c0ad7abc1200f9ad42c4adc6fbb4bd44b5bed4667990e64565102c1ba6" + ) +) +``` diff --git a/eth_pydantic_types/__init__.py b/eth_pydantic_types/__init__.py index f367625..9fed8e1 100644 --- a/eth_pydantic_types/__init__.py +++ b/eth_pydantic_types/__init__.py @@ -1,7 +1,7 @@ from .address import Address +from .bip122 import Bip122Uri from .hash import Hash4, Hash8, Hash16, Hash20, Hash32, Hash64 from .hex import HexBytes, HexStr -from .bip122 import Bip122Uri __all__ = [ "Address", diff --git a/eth_pydantic_types/_error.py b/eth_pydantic_types/_error.py index 90642bb..ba48ae6 100644 --- a/eth_pydantic_types/_error.py +++ b/eth_pydantic_types/_error.py @@ -23,5 +23,5 @@ def Bip122UriFormatError(value: str) -> PydanticCustomError: Bip122UriFormatError, "BIP-122 URI format", uri=value, - format="blockchain:///block/." + format="blockchain:///block/.", ) diff --git a/eth_pydantic_types/bip122.py b/eth_pydantic_types/bip122.py index b1db3a5..5c0a2ef 100644 --- a/eth_pydantic_types/bip122.py +++ b/eth_pydantic_types/bip122.py @@ -1,17 +1,28 @@ from typing import Any, Optional -from pydantic_core import CoreSchema, PydanticCustomError +from pydantic_core import CoreSchema from pydantic_core.core_schema import ( + ValidationInfo, str_schema, with_info_before_validator_function, - ValidationInfo, ) -from eth_pydantic_types.hex import validate_hex_str from eth_pydantic_types._error import Bip122UriFormatError +from eth_pydantic_types.hex import validate_hex_str class Bip122Uri(str): + @classmethod + def __get_pydantic_json_schema__(cls, core_schema, handler): + json_schema = handler(core_schema) + example = ( + "blockchain://d4e56740f876aef8c010b86a40d5f56745a118d0906a34e69aec8c0db1cb8fa3" + "/block/752820c0ad7abc1200f9ad42c4adc6fbb4bd44b5bed4667990e64565102c1ba6" + ) + pattern = "^blockchain://[0-9a-f]{64}/block/[0-9a-f]{64}$" + json_schema.update(examples=[example], pattern=pattern) + return json_schema + def __get_pydantic_core_schema__(self, *args, **kwargs) -> CoreSchema: schema = with_info_before_validator_function( self._validate, @@ -35,6 +46,6 @@ def _validate(cls, value: Any, info: Optional[ValidationInfo] = None) -> str: if block_keyword != "block": raise Bip122UriFormatError(value) - validated_genesis_hash = validate_hex_str(genesis_hash) - validated_block_hash = validate_hex_str(block_hash) + validated_genesis_hash = validate_hex_str(genesis_hash)[2:] + validated_block_hash = validate_hex_str(block_hash)[2:] return f"{prefix}{validated_genesis_hash}/{block_keyword}/{validated_block_hash}" diff --git a/tests/test_bip122.py b/tests/test_bip122.py new file mode 100644 index 0000000..8028add --- /dev/null +++ b/tests/test_bip122.py @@ -0,0 +1,40 @@ +import pytest +from pydantic import BaseModel, ValidationError + +from eth_pydantic_types.bip122 import Bip122Uri + +GENESIS_HASH = "d4e56740f876aef8c010b86a40d5f56745a118d0906a34e69aec8c0db1cb8fa3" +BLOCK_HASH = "752820c0ad7abc1200f9ad42c4adc6fbb4bd44b5bed4667990e64565102c1ba6" +EXAMPLE = f"blockchain://{GENESIS_HASH}/block/{BLOCK_HASH}" + + +class Model(BaseModel): + uri: Bip122Uri + + +def test_bip122(): + model = Model(uri=EXAMPLE) + assert model.uri == EXAMPLE + + +@pytest.mark.parametrize( + "uri", + ( + "foo", + f"blockchain://foo/block/{BLOCK_HASH}", + f"blockchain://{GENESIS_HASH}/block/foo", + f"blockchain://{GENESIS_HASH}/tx/foo", + ), +) +def test_invalid_bip122(uri): + with pytest.raises(ValidationError): + Model(uri=uri) + + +def test_schema(): + actual = Model.model_json_schema() + prop = actual["properties"]["uri"] + assert prop["type"] == "string" + assert prop["title"] == "Uri" + assert prop["examples"] == [EXAMPLE] + assert prop["pattern"] == "^blockchain://[0-9a-f]{64}/block/[0-9a-f]{64}$" From 92d70ae7238bf9b4cd66417393d8717c6b554e00 Mon Sep 17 00:00:00 2001 From: Juliya Smith Date: Wed, 11 Oct 2023 11:38:10 -0500 Subject: [PATCH 05/10] fix: from bytes override --- eth_pydantic_types/hex.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/eth_pydantic_types/hex.py b/eth_pydantic_types/hex.py index 47ea196..d8f8e2b 100644 --- a/eth_pydantic_types/hex.py +++ b/eth_pydantic_types/hex.py @@ -78,6 +78,10 @@ class HexStr(BaseHexStr): def __get_pydantic_core_schema__(cls, *args, **kwargs): return no_info_before_validator_function(cls.validate_hex, str_schema()) + @classmethod + def from_bytes(cls, data: bytes) -> "HexStr": + return HexStr(super().from_bytes(data)) + @classmethod def validate_hex(cls, data: Union[bytes, str, int]): if isinstance(data, bytes): From b5e0657965a1693392593f742430ecf1579bccfb Mon Sep 17 00:00:00 2001 From: Juliya Smith Date: Wed, 11 Oct 2023 13:27:49 -0500 Subject: [PATCH 06/10] feat: hashstr --- README.md | 17 +++-- eth_pydantic_types/__init__.py | 37 +++++++++-- eth_pydantic_types/address.py | 2 +- eth_pydantic_types/hash.py | 90 ++++++++++++++++++------- eth_pydantic_types/hex.py | 26 ++++---- eth_pydantic_types/validators.py | 10 ++- tests/test_hash.py | 109 ++++++++++++++++++++++--------- tests/test_hex.py | 5 ++ 8 files changed, 211 insertions(+), 85 deletions(-) diff --git a/README.md b/README.md index 4e4ea7e..bdd1d59 100644 --- a/README.md +++ b/README.md @@ -2,25 +2,28 @@ The types in this package are pydantic types for Ethereum inspired from [eth-typing](https://github.com/ethereum/eth-typing/blob/master/eth_typing/evm.py). -## Hash32 +## Hash -Hash32 is a good type to use for Ethereum transaction hashes. -`Hash` types serialize to bytes in the Pydantic core schema and `string` in the JSON schema with a binary format. -Use Hash32 like this: +`HashBytes{n}` and `HashStr{n}` are good types to use when your hex values are sized. +Both types serialize to `string` in the JSON schema. +Use `HashBytes` types when you want types to serialize to bytes in the Pydantic core schema and `HashStr` types when you want to serialize to `str` in the core Pydantic schema. ```python from pydantic import BaseModel -from eth_pydantic_types import Hash32 +from eth_pydantic_types import HashBytes32, HashStr20 +# When serializing to JSON, both types are hex strings. class Transaction(BaseModel): - tx_hash: Hash32 + tx_hash: HashBytes32 # Will be bytes + address: HashStr20 # Will be str # NOTE: I am able to pass an int-hash as the value and it will # get validated and type-coerced. tx = Transaction( - tx_hash=0x1031f0c9ac54dcb64b4f121a27957c14263c5cb49ed316d568e41e19c34d7b28 + tx_hash=0x1031f0c9ac54dcb64b4f121a27957c14263c5cb49ed316d568e41e19c34d7b28, + address=0x1031f0c9ac54dcb64b4f121a27957c14263c5cb4, ) ``` diff --git a/eth_pydantic_types/__init__.py b/eth_pydantic_types/__init__.py index 9fed8e1..58d21d0 100644 --- a/eth_pydantic_types/__init__.py +++ b/eth_pydantic_types/__init__.py @@ -1,17 +1,40 @@ from .address import Address from .bip122 import Bip122Uri -from .hash import Hash4, Hash8, Hash16, Hash20, Hash32, Hash64 +from .hash import ( + HashBytes, + HashBytes4, + HashBytes8, + HashBytes16, + HashBytes20, + HashBytes32, + HashBytes64, + HashStr, + HashStr4, + HashStr8, + HashStr16, + HashStr20, + HashStr32, + HashStr64, +) from .hex import HexBytes, HexStr __all__ = [ "Address", "Bip122Uri", - "Hash4", - "Hash8", - "Hash16", - "Hash20", - "Hash32", - "Hash64", + "HashBytes", + "HashBytes4", + "HashBytes8", + "HashBytes16", + "HashBytes20", + "HashBytes32", + "HashBytes64", + "HashStr", + "HashStr4", + "HashStr8", + "HashStr16", + "HashStr20", + "HashStr32", + "HashStr64", "HexBytes", "HexStr", ] diff --git a/eth_pydantic_types/address.py b/eth_pydantic_types/address.py index 5da018d..d34c8a9 100644 --- a/eth_pydantic_types/address.py +++ b/eth_pydantic_types/address.py @@ -46,6 +46,6 @@ def _validate_address(cls, value: Any, info: Optional[ValidationInfo] = None) -> value = HexBytes(value).hex() number = value[2:] if value.startswith("0x") else value - number_padded = validate_address_size(number, 40) + number_padded = validate_address_size(number) value = f"0x{number_padded}" return to_checksum_address(value) diff --git a/eth_pydantic_types/hash.py b/eth_pydantic_types/hash.py index ae8fa9c..c8d5585 100644 --- a/eth_pydantic_types/hash.py +++ b/eth_pydantic_types/hash.py @@ -1,35 +1,36 @@ -from typing import Any, ClassVar, Optional, Tuple +from typing import Any, ClassVar, Optional, Tuple, Type from pydantic_core.core_schema import ( CoreSchema, ValidationInfo, bytes_schema, + str_schema, with_info_before_validator_function, ) -from eth_pydantic_types.hex import HexBytes +from eth_pydantic_types.hex import BaseHexStr, HexBytes from eth_pydantic_types.serializers import hex_serializer -from eth_pydantic_types.validators import validate_bytes_size +from eth_pydantic_types.validators import validate_bytes_size, validate_str_size -def get_hash_pattern(size: int) -> str: - return f"^0x[a-fA-F0-9]{{{size}}}$" +def _get_hash_pattern(str_size: int) -> str: + return f"^0x[a-fA-F0-9]{{{str_size}}}$" -def get_hash_examples(size: int) -> Tuple[str, str]: - return f"0x{'0' * (size * 2)}", f"0x{'1e' * size}" +def _get_hash_examples(str_size: int) -> Tuple[str, str]: + return f"0x{'0' * str_size}", f"0x{'1e' * (str_size // 2)}" -class Hash(HexBytes): +class HashBytes(HexBytes): """ - Represents a single-slot static hash. + Represents a single-slot static hash as bytes. The class variable "size" is overridden in subclasses for each byte-size, - e.g. Hash4, Hash20, Hash32. + e.g. HashBytes4, HashBytes20, HashBytes32. """ size: ClassVar[int] = 1 - schema_pattern: ClassVar[str] = get_hash_pattern(1) - schema_examples: ClassVar[Tuple[str, ...]] = get_hash_examples(1) + schema_pattern: ClassVar[str] = _get_hash_pattern(1) + schema_examples: ClassVar[Tuple[str, ...]] = _get_hash_examples(1) def __get_pydantic_core_schema__(self, *args, **kwargs) -> CoreSchema: schema = with_info_before_validator_function( @@ -47,21 +48,64 @@ def validate_size(cls, value: bytes) -> bytes: return validate_bytes_size(value, cls.size) -def make_hash_cls(size: int): +class HashStr(BaseHexStr): + """ + Represents a single-slot static hash as a str. + The class variable "size" is overridden in subclasses for each byte-size, + e.g. HashStr4, HashStr20, HashStr32. + """ + + size: ClassVar[int] = 1 + schema_pattern: ClassVar[str] = _get_hash_pattern(1) + schema_examples: ClassVar[Tuple[str, ...]] = _get_hash_examples(1) + + def __get_pydantic_core_schema__(self, *args, **kwargs) -> CoreSchema: + str_size = self.size * 2 + 2 + return with_info_before_validator_function( + self._validate_hash, str_schema(max_length=str_size, min_length=str_size) + ) + + @classmethod + def _validate_hash(cls, value: Any, info: Optional[ValidationInfo] = None) -> str: + hex_str = cls.validate_hex(value) + hex_value = hex_str[2:] if hex_str.startswith("0x") else hex_str + sized_value = cls.validate_size(hex_value) + return cls(f"0x{sized_value}") + + @classmethod + def validate_size(cls, value: str) -> str: + return validate_str_size(value, cls.size * 2) + + +def _make_hash_cls(size: int, base_type: Type): + if issubclass(base_type, bytes): + suffix = "Bytes" + base_type = HashBytes + else: + suffix = "Str" + base_type = HashStr + + str_size = size * 2 return type( - f"Hash{size}", - (Hash,), + f"Hash{suffix}{size}", + (base_type,), dict( size=size, - schema_pattern=get_hash_pattern(size), - schema_examples=get_hash_examples(size), + schema_pattern=_get_hash_pattern(str_size), + schema_examples=_get_hash_examples(str_size), ), ) -Hash4 = make_hash_cls(4) -Hash8 = make_hash_cls(8) -Hash16 = make_hash_cls(16) -Hash20 = make_hash_cls(20) -Hash32 = make_hash_cls(32) -Hash64 = make_hash_cls(64) +HashBytes4 = _make_hash_cls(4, bytes) +HashBytes8 = _make_hash_cls(8, bytes) +HashBytes16 = _make_hash_cls(16, bytes) +HashBytes20 = _make_hash_cls(20, bytes) +HashBytes32 = _make_hash_cls(32, bytes) +HashBytes64 = _make_hash_cls(64, bytes) +HashStr4 = _make_hash_cls(4, str) +HashStr8 = _make_hash_cls(8, str) +HashStr16 = _make_hash_cls(16, str) +HashStr20 = _make_hash_cls(20, str) +HashStr32 = _make_hash_cls(32, str) +HashStr64 = _make_hash_cls(64, str) diff --git a/eth_pydantic_types/hex.py b/eth_pydantic_types/hex.py index d8f8e2b..9672b9b 100644 --- a/eth_pydantic_types/hex.py +++ b/eth_pydantic_types/hex.py @@ -65,6 +65,19 @@ def from_bytes(cls, data: bytes) -> "BaseHexStr": hex_str = data.hex() return cls(hex_str if hex_str.startswith("0x") else hex_str) + @classmethod + def validate_hex(cls, data: Union[bytes, str, int]): + if isinstance(data, bytes): + return cls.from_bytes(data) + + elif isinstance(data, str): + return validate_hex_str(data) + + elif isinstance(data, int): + return BaseHexBytes(data).hex() + + raise HexValueError(data) + def __int__(self) -> int: return int(self, 16) @@ -82,19 +95,6 @@ def __get_pydantic_core_schema__(cls, *args, **kwargs): def from_bytes(cls, data: bytes) -> "HexStr": return HexStr(super().from_bytes(data)) - @classmethod - def validate_hex(cls, data: Union[bytes, str, int]): - if isinstance(data, bytes): - return cls.from_bytes(data) - - elif isinstance(data, str): - return validate_hex_str(data) - - elif isinstance(data, int): - return BaseHexBytes(data).hex() - - raise HexValueError(data) - def validate_hex_str(value: str) -> str: hex_value = (value[2:] if value.startswith("0x") else value).lower() diff --git a/eth_pydantic_types/validators.py b/eth_pydantic_types/validators.py index ce07cca..a80d607 100644 --- a/eth_pydantic_types/validators.py +++ b/eth_pydantic_types/validators.py @@ -29,12 +29,16 @@ def validate_bytes_size(value: bytes, size: int) -> bytes: return validate_size(value, size, coerce=lambda v: _left_pad_bytes(v, size)) -def validate_address_size(value: str, size: int) -> str: +def validate_address_size(value: str) -> str: + return validate_str_size(value, 40) + + +def validate_str_size(value: str, size: int) -> str: return validate_size(value, size, coerce=lambda v: _left_pad_str(v, size)) -def _left_pad_str(val: str, num_bytes: int) -> str: - return "0" * (num_bytes - len(val)) + val if len(val) < num_bytes else val +def _left_pad_str(val: str, length: int) -> str: + return "0" * (length - len(val)) + val if len(val) < length else val def _left_pad_bytes(val: bytes, num_bytes: int) -> bytes: diff --git a/tests/test_hash.py b/tests/test_hash.py index c82ecf6..0d2b899 100644 --- a/tests/test_hash.py +++ b/tests/test_hash.py @@ -1,67 +1,114 @@ import pytest from pydantic import BaseModel, ValidationError -from eth_pydantic_types.hash import Hash8, Hash16, Hash32, Hash64 +from eth_pydantic_types.hash import ( + HashBytes8, + HashBytes16, + HashBytes32, + HashBytes64, + HashStr8, + HashStr16, + HashStr32, + HashStr64, +) from eth_pydantic_types.hex import HexBytes class Model(BaseModel): - value8: Hash8 - value16: Hash16 - value32: Hash32 - value64: Hash64 + valuebytes8: HashBytes8 + valuebytes16: HashBytes16 + valuebytes32: HashBytes32 + valuebytes64: HashBytes64 + valuestr8: HashStr8 + valuestr16: HashStr16 + valuestr32: HashStr32 + valuestr64: HashStr64 + @classmethod + def from_single(cls, value): + return cls( + valuebytes8=value, + valuebytes16=value, + valuebytes32=value, + valuebytes64=value, + valuestr8=value, + valuestr16=value, + valuestr32=value, + valuestr64=value, + ) -def test_fromhex(bytes32str): - actual_with_0x = Hash32.fromhex(bytes32str) - actual_without_0x = Hash32.fromhex(bytes32str[2:]) + +def test_hashbytes_fromhex(bytes32str): + actual_with_0x = HashBytes32.fromhex(bytes32str) + actual_without_0x = HashBytes32.fromhex(bytes32str[2:]) expected = HexBytes(bytes32str) assert actual_with_0x == actual_without_0x == expected -def test_hash_is_bytes(bytes32str): - assert isinstance(Hash32.fromhex(bytes32str), bytes) +def test_hashbytes_is_bytes(bytes32str): + assert isinstance(HashBytes32.fromhex(bytes32str), bytes) @pytest.mark.parametrize("value", ("0x32", HexBytes("0x32"), b"2", 50)) def test_hash(value): - model = Model(value8=value, value16=value, value32=value, value64=value) - assert len(model.value8) == 8 - assert len(model.value16) == 16 - assert len(model.value32) == 32 - assert len(model.value64) == 64 - assert model.value8.hex().endswith("32") - assert model.value16.hex().endswith("32") - assert model.value32.hex().endswith("32") - assert model.value64.hex().endswith("32") + model = Model.from_single(value) + assert len(model.valuebytes8) == 8 + assert len(model.valuebytes16) == 16 + assert len(model.valuebytes32) == 32 + assert len(model.valuebytes64) == 64 + assert len(model.valuestr8) == 18 + assert len(model.valuestr16) == 34 + assert len(model.valuestr32) == 66 + assert len(model.valuestr64) == 130 + assert model.valuebytes8.hex().endswith("32") + assert model.valuebytes16.hex().endswith("32") + assert model.valuebytes32.hex().endswith("32") + assert model.valuestr64.endswith("32") + assert model.valuestr8.endswith("32") + assert model.valuestr16.endswith("32") + assert model.valuestr32.endswith("32") + assert model.valuestr64.endswith("32") @pytest.mark.parametrize("value", ("foo", -35, "0x" + ("F" * 100))) def test_invalid_hash(value): with pytest.raises(ValidationError): - Model(value8=value, value16=value, value32=value, value64=value) + Model.from_single(value) def test_schema(): actual = Model.model_json_schema() for name, prop in actual["properties"].items(): - size_from_name = int(name.replace("value", "")) - assert prop["maxLength"] == size_from_name - assert prop["minLength"] == size_from_name + is_bytes = "bytes" in name + if is_bytes: + size_from_name = int(name.replace("valuebytes", "")) + expected_length = size_from_name + else: + size_from_name = int(name.replace("valuestr", "")) + expected_length = size_from_name * 2 + 2 + + hex_value_str_size = size_from_name * 2 + + assert prop["maxLength"] == expected_length + assert prop["minLength"] == expected_length assert prop["type"] == "string" assert prop["format"] == "binary" - assert prop["pattern"] == f"^0x[a-fA-F0-9]{{{size_from_name}}}$" - assert prop["examples"][0] == f"0x{'0' * size_from_name * 2}" - assert len(prop["examples"][1]) == size_from_name * 2 + 2 + assert prop["pattern"] == f"^0x[a-fA-F0-9]{{{hex_value_str_size}}}$" + assert prop["examples"][0] == f"0x{'0' * hex_value_str_size}" + assert len(prop["examples"][1]) == hex_value_str_size + 2 def test_model_dump(bytes32str): - model = Model(value8=5, value16=5, value32=5, value64=5) + model = Model.from_single(5) actual = model.model_dump() expected = { - "value16": "0x00000000000000000000000000000005", - "value32": "0x0000000000000000000000000000000000000000000000000000000000000005", - "value64": "0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000005", # noqa: E501 - "value8": "0x0000000000000005", + "valuebytes8": "0x0000000000000005", + "valuestr8": "0x0000000000000005", + "valuebytes16": "0x00000000000000000000000000000005", + "valuestr16": "0x00000000000000000000000000000005", + "valuebytes32": "0x0000000000000000000000000000000000000000000000000000000000000005", + "valuestr32": "0x0000000000000000000000000000000000000000000000000000000000000005", + "valuebytes64": "0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000005", # noqa: E501 + "valuestr64": "0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000005", # noqa: E501 } assert actual == expected diff --git a/tests/test_hex.py b/tests/test_hex.py index 2e1a6c1..9c47def 100644 --- a/tests/test_hex.py +++ b/tests/test_hex.py @@ -104,3 +104,8 @@ def test_hexstr_model_dump(bytes32str): actual = model.model_dump() expected = {"value": "0x9b70bd98ccb5b6434c2ead14d68d15f392435a06ff469f8d1f8cf38b2ae0b0e2"} assert actual == expected + + model = StrModel(value=3) + actual = model.model_dump() + expected = {"value": "0x03"} + assert actual == expected From 97993662097ed2e05ec241fdc3945075f09dc6f4 Mon Sep 17 00:00:00 2001 From: Juliya Smith Date: Wed, 11 Oct 2023 15:16:48 -0500 Subject: [PATCH 07/10] feat: examples --- eth_pydantic_types/address.py | 4 +++- eth_pydantic_types/hash.py | 8 ++++++-- tests/test_address.py | 2 ++ tests/test_hash.py | 5 ++++- 4 files changed, 15 insertions(+), 4 deletions(-) diff --git a/eth_pydantic_types/address.py b/eth_pydantic_types/address.py index d34c8a9..c13badf 100644 --- a/eth_pydantic_types/address.py +++ b/eth_pydantic_types/address.py @@ -26,7 +26,9 @@ class Address(BaseHexStr): schema_pattern: ClassVar[str] = ADDRESS_PATTERN schema_examples: ClassVar[Tuple[str, ...]] = ( - "0x0000000000000000000000000000000000000000", # empty address + "0x0000000000000000000000000000000000000000", # Zero address + "0x02c84e944F97F4A4f60221e6fb5d5DbAE49c7aaB", # Leading zero + "0xa5a13f62ce1113838e0d9b4559b8caf5f76463c0", # Trailing zero "0x1e59ce931B4CFea3fe4B875411e280e173cB7A9C", ) diff --git a/eth_pydantic_types/hash.py b/eth_pydantic_types/hash.py index c8d5585..ea4a670 100644 --- a/eth_pydantic_types/hash.py +++ b/eth_pydantic_types/hash.py @@ -17,8 +17,12 @@ def _get_hash_pattern(str_size: int) -> str: return f"^0x[a-fA-F0-9]{{{str_size}}}$" -def _get_hash_examples(str_size: int) -> Tuple[str, str]: - return f"0x{'0' * str_size}", f"0x{'1e' * (str_size // 2)}" +def _get_hash_examples(str_size: int) -> Tuple[str, str, str, str]: + zero_hash = f"0x{'0' * str_size}" + leading_zero = f"0x01{'1e' * ((str_size - 1) // 2)}" + trailing_zero = f"0x{'1e' * ((str_size - 1) // 2)}10" + full_hash = f"0x{'1e' * (str_size // 2)}" + return zero_hash, leading_zero, trailing_zero, full_hash class HashBytes(HexBytes): diff --git a/tests/test_address.py b/tests/test_address.py index 7799c5e..babdca8 100644 --- a/tests/test_address.py +++ b/tests/test_address.py @@ -50,6 +50,8 @@ def test_schema(): assert prop["pattern"] == "^0x[a-fA-F0-9]{40}$" assert prop["examples"] == [ "0x0000000000000000000000000000000000000000", + "0x02c84e944F97F4A4f60221e6fb5d5DbAE49c7aaB", + "0xa5a13f62ce1113838e0d9b4559b8caf5f76463c0", "0x1e59ce931B4CFea3fe4B875411e280e173cB7A9C", ] diff --git a/tests/test_hash.py b/tests/test_hash.py index 0d2b899..167fe57 100644 --- a/tests/test_hash.py +++ b/tests/test_hash.py @@ -94,8 +94,11 @@ def test_schema(): assert prop["type"] == "string" assert prop["format"] == "binary" assert prop["pattern"] == f"^0x[a-fA-F0-9]{{{hex_value_str_size}}}$" + assert prop["examples"][0] == f"0x{'0' * hex_value_str_size}" - assert len(prop["examples"][1]) == hex_value_str_size + 2 + assert prop["examples"][1].startswith("0x0") # Leading zero + assert prop["examples"][2].endswith("0") # Trailing zero + assert all(len(ex) == hex_value_str_size + 2 for ex in prop["examples"]) def test_model_dump(bytes32str): From 7d363644eafd089ac1ae284eb2a5e43bd430ff5d Mon Sep 17 00:00:00 2001 From: Juliya Smith Date: Wed, 11 Oct 2023 15:21:09 -0500 Subject: [PATCH 08/10] feat: all bip122 types --- eth_pydantic_types/bip122.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/eth_pydantic_types/bip122.py b/eth_pydantic_types/bip122.py index 5c0a2ef..41ae3c6 100644 --- a/eth_pydantic_types/bip122.py +++ b/eth_pydantic_types/bip122.py @@ -1,3 +1,4 @@ +from enum import Enum from typing import Any, Optional from pydantic_core import CoreSchema @@ -11,6 +12,12 @@ from eth_pydantic_types.hex import validate_hex_str +class Bip122UriType(Enum): + TX = "tx" + BLOCK = "block" + ADDRESS = "address" + + class Bip122Uri(str): @classmethod def __get_pydantic_json_schema__(cls, core_schema, handler): @@ -42,8 +49,8 @@ def _validate(cls, value: Any, info: Optional[ValidationInfo] = None) -> str: raise Bip122UriFormatError(value) genesis_hash, block_keyword, block_hash = protocol_parsed - - if block_keyword != "block": + block_keyword = block_keyword.lower() + if block_keyword not in [x.value for x in Bip122UriType]: raise Bip122UriFormatError(value) validated_genesis_hash = validate_hex_str(genesis_hash)[2:] From 66b7cc13881880d9ecb68256c170b8357cdd9099 Mon Sep 17 00:00:00 2001 From: Juliya Smith Date: Wed, 11 Oct 2023 15:40:59 -0500 Subject: [PATCH 09/10] feat: bip122 parsed --- eth_pydantic_types/bip122.py | 49 +++++++++++++++++++++++++++--------- tests/test_bip122.py | 13 +++++++--- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/eth_pydantic_types/bip122.py b/eth_pydantic_types/bip122.py index 41ae3c6..52063c4 100644 --- a/eth_pydantic_types/bip122.py +++ b/eth_pydantic_types/bip122.py @@ -1,5 +1,6 @@ from enum import Enum -from typing import Any, Optional +from functools import cached_property +from typing import Any, Optional, Tuple from pydantic_core import CoreSchema from pydantic_core.core_schema import ( @@ -19,31 +20,37 @@ class Bip122UriType(Enum): class Bip122Uri(str): + prefix: str = "blockchain://" + @classmethod def __get_pydantic_json_schema__(cls, core_schema, handler): json_schema = handler(core_schema) example = ( - "blockchain://d4e56740f876aef8c010b86a40d5f56745a118d0906a34e69aec8c0db1cb8fa3" - "/block/752820c0ad7abc1200f9ad42c4adc6fbb4bd44b5bed4667990e64565102c1ba6" + f"{cls.prefix}d4e56740f876aef8c010b86a40d5f56745a118d0906a34e69aec8c0db1cb8fa3" + f"/{Bip122UriType.BLOCK.value}/" + f"752820c0ad7abc1200f9ad42c4adc6fbb4bd44b5bed4667990e64565102c1ba6" ) - pattern = "^blockchain://[0-9a-f]{64}/block/[0-9a-f]{64}$" + pattern = f"^{cls.prefix}[0-9a-f]{{64}}/{Bip122UriType.BLOCK.value}/[0-9a-f]{{64}}$" json_schema.update(examples=[example], pattern=pattern) return json_schema def __get_pydantic_core_schema__(self, *args, **kwargs) -> CoreSchema: - schema = with_info_before_validator_function( + return with_info_before_validator_function( self._validate, str_schema(), ) - return schema @classmethod def _validate(cls, value: Any, info: Optional[ValidationInfo] = None) -> str: - prefix = "blockchain://" - if not value.startswith(prefix): + if not value.startswith(cls.prefix): raise Bip122UriFormatError(value) - protocol_suffix = value.replace(prefix, "") + genesis_hash, block_keyword, block_hash = cls.parse(value) + return f"{cls.prefix}{genesis_hash[2:]}/{block_keyword.value}/{block_hash[2:]}" + + @classmethod + def parse(cls, value: str) -> Tuple[str, Bip122UriType, str]: + protocol_suffix = value.replace(cls.prefix, "") protocol_parsed = protocol_suffix.split("/") if len(protocol_parsed) != 3: raise Bip122UriFormatError(value) @@ -53,6 +60,24 @@ def _validate(cls, value: Any, info: Optional[ValidationInfo] = None) -> str: if block_keyword not in [x.value for x in Bip122UriType]: raise Bip122UriFormatError(value) - validated_genesis_hash = validate_hex_str(genesis_hash)[2:] - validated_block_hash = validate_hex_str(block_hash)[2:] - return f"{prefix}{validated_genesis_hash}/{block_keyword}/{validated_block_hash}" + return ( + validate_hex_str(genesis_hash), + Bip122UriType(block_keyword), + validate_hex_str(block_hash), + ) + + @cached_property + def parsed(self) -> Tuple[str, Bip122UriType, str]: + return self.parse(self) + + @property + def chain(self) -> str: + return self.parsed[0] + + @property + def uri_type(self) -> Bip122UriType: + return self.parsed[1] + + @property + def hash(self) -> str: + return self.parsed[2] diff --git a/tests/test_bip122.py b/tests/test_bip122.py index 8028add..6edc8fe 100644 --- a/tests/test_bip122.py +++ b/tests/test_bip122.py @@ -1,7 +1,7 @@ import pytest from pydantic import BaseModel, ValidationError -from eth_pydantic_types.bip122 import Bip122Uri +from eth_pydantic_types.bip122 import Bip122Uri, Bip122UriType GENESIS_HASH = "d4e56740f876aef8c010b86a40d5f56745a118d0906a34e69aec8c0db1cb8fa3" BLOCK_HASH = "752820c0ad7abc1200f9ad42c4adc6fbb4bd44b5bed4667990e64565102c1ba6" @@ -12,9 +12,14 @@ class Model(BaseModel): uri: Bip122Uri -def test_bip122(): - model = Model(uri=EXAMPLE) - assert model.uri == EXAMPLE +def test_parsed(): + # NOTE: This won't work in the model itself because + # it turns to `str` after validation, much like URL pydantic classes. + uri = Bip122Uri(EXAMPLE) + assert uri == EXAMPLE + assert uri.uri_type == Bip122UriType.BLOCK + assert uri.chain == f"0x{GENESIS_HASH}" + assert uri.hash == f"0x{BLOCK_HASH}" @pytest.mark.parametrize( From dacb45df4f07c41fcd4b3f8bcf516cdc1d62f4d2 Mon Sep 17 00:00:00 2001 From: Juliya Smith Date: Wed, 11 Oct 2023 15:49:27 -0500 Subject: [PATCH 10/10] docs: fix hash docs --- eth_pydantic_types/__init__.py | 4 ---- eth_pydantic_types/hash.py | 7 ++++--- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/eth_pydantic_types/__init__.py b/eth_pydantic_types/__init__.py index 58d21d0..f6efb3b 100644 --- a/eth_pydantic_types/__init__.py +++ b/eth_pydantic_types/__init__.py @@ -1,14 +1,12 @@ from .address import Address from .bip122 import Bip122Uri from .hash import ( - HashBytes, HashBytes4, HashBytes8, HashBytes16, HashBytes20, HashBytes32, HashBytes64, - HashStr, HashStr4, HashStr8, HashStr16, @@ -21,14 +19,12 @@ __all__ = [ "Address", "Bip122Uri", - "HashBytes", "HashBytes4", "HashBytes8", "HashBytes16", "HashBytes20", "HashBytes32", "HashBytes64", - "HashStr", "HashStr4", "HashStr8", "HashStr16", diff --git a/eth_pydantic_types/hash.py b/eth_pydantic_types/hash.py index ea4a670..468eb7b 100644 --- a/eth_pydantic_types/hash.py +++ b/eth_pydantic_types/hash.py @@ -28,8 +28,9 @@ def _get_hash_examples(str_size: int) -> Tuple[str, str, str, str]: class HashBytes(HexBytes): """ Represents a single-slot static hash as bytes. + This type is meant to be overridden by the larger hash types with a new size. The class variable "size" is overridden in subclasses for each byte-size, - e.g. HashBytes4, HashBytes20, HashBytes32. + e.g. HashBytes20, HashBytes32. """ size: ClassVar[int] = 1 @@ -55,8 +56,8 @@ def validate_size(cls, value: bytes) -> bytes: class HashStr(BaseHexStr): """ Represents a single-slot static hash as a str. - The class variable "size" is overridden in subclasses for each byte-size, - e.g. HashStr4, HashStr20, HashStr32. + This type is meant to be overridden by the larger hash types with a new size. + e.g. HashStr20, HashStr32. """ size: ClassVar[int] = 1