Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: HexStr, Bip122Uri, and HashStr types #1

Merged
merged 10 commits into from
Oct 11, 2023
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/docs.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ on:
jobs:
docs:
runs-on: ubuntu-latest
permissions:
contents: write

steps:
- uses: actions/checkout@v3
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/draft.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
39 changes: 38 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -57,3 +59,38 @@ 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")
```

## 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"
)
)
```
File renamed without changes.
16 changes: 14 additions & 2 deletions eth_pydantic_types/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,17 @@
from .address import Address
from .bip122 import Bip122Uri
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",
"Bip122Uri",
"Hash4",
"Hash8",
"Hash16",
"Hash20",
"Hash32",
"Hash64",
"HexBytes",
"HexStr",
]
27 changes: 27 additions & 0 deletions eth_pydantic_types/_error.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
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 CustomError(fn: Callable, invalid_tag: str, **kwargs) -> PydanticCustomError:
return PydanticCustomError(fn.__name__, f"Invalid {invalid_tag}", kwargs)


def HexValueError(value: Any) -> PydanticCustomError:
return CustomError(HexValueError, "hex 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://<genesis_hash>/block/<block_hash>.",
)
26 changes: 16 additions & 10 deletions eth_pydantic_types/address.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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",
)
antazoey marked this conversation as resolved.
Show resolved Hide resolved

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

Expand All @@ -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)
51 changes: 51 additions & 0 deletions eth_pydantic_types/bip122.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
from typing import Any, Optional

from pydantic_core import CoreSchema
from pydantic_core.core_schema import (
ValidationInfo,
str_schema,
with_info_before_validator_function,
)

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,
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":
antazoey marked this conversation as resolved.
Show resolved Hide resolved
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}"
78 changes: 30 additions & 48 deletions eth_pydantic_types/hash.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from typing import Any, ClassVar, Optional
from typing import Any, ClassVar, Optional, Tuple

from pydantic_core.core_schema import (
CoreSchema,
Expand All @@ -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}}}$"

antazoey marked this conversation as resolved.
Show resolved Hide resolved

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