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 all 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
54 changes: 47 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
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.
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,
)
```

Expand All @@ -29,6 +32,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 +49,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 +62,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.
37 changes: 34 additions & 3 deletions eth_pydantic_types/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,36 @@
from .address import Address
from .hash import Hash4, Hash8, Hash16, Hash20, Hash32, Hash64
from .hexbytes import HexBytes
from .bip122 import Bip122Uri
from .hash import (
HashBytes4,
HashBytes8,
HashBytes16,
HashBytes20,
HashBytes32,
HashBytes64,
HashStr4,
HashStr8,
HashStr16,
HashStr20,
HashStr32,
HashStr64,
)
from .hex import HexBytes, HexStr

__all__ = ["Address", "Hash4", "Hash8", "Hash16", "Hash20", "Hash32", "Hash64", "HexBytes"]
__all__ = [
"Address",
"Bip122Uri",
"HashBytes4",
"HashBytes8",
"HashBytes16",
"HashBytes20",
"HashBytes32",
"HashBytes64",
"HashStr4",
"HashStr8",
"HashStr16",
"HashStr20",
"HashStr32",
"HashStr64",
"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>.",
)
30 changes: 19 additions & 11 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,34 @@
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", # Zero address
"0x02c84e944F97F4A4f60221e6fb5d5DbAE49c7aaB", # Leading zero
"0xa5a13f62ce1113838e0d9b4559b8caf5f76463c0", # Trailing zero
"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 @@ -34,12 +48,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)

def __int__(self) -> int:
return int(self, 16)

def __bytes__(self) -> HexBytes:
return HexBytes(self)
83 changes: 83 additions & 0 deletions eth_pydantic_types/bip122.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
from enum import Enum
from functools import cached_property
from typing import Any, Optional, Tuple

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 Bip122UriType(Enum):
TX = "tx"
BLOCK = "block"
ADDRESS = "address"


class Bip122Uri(str):
prefix: str = "blockchain://"

@classmethod
def __get_pydantic_json_schema__(cls, core_schema, handler):
json_schema = handler(core_schema)
example = (
f"{cls.prefix}d4e56740f876aef8c010b86a40d5f56745a118d0906a34e69aec8c0db1cb8fa3"
f"/{Bip122UriType.BLOCK.value}/"
f"752820c0ad7abc1200f9ad42c4adc6fbb4bd44b5bed4667990e64565102c1ba6"
)
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:
return with_info_before_validator_function(
self._validate,
str_schema(),
)

@classmethod
def _validate(cls, value: Any, info: Optional[ValidationInfo] = None) -> str:
if not value.startswith(cls.prefix):
raise Bip122UriFormatError(value)

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)

genesis_hash, block_keyword, block_hash = protocol_parsed
block_keyword = block_keyword.lower()
if block_keyword not in [x.value for x in Bip122UriType]:
raise Bip122UriFormatError(value)

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]
Loading
Loading