Skip to content

Commit

Permalink
feat(fw): Add sha256 hash to fixtures (#454)
Browse files Browse the repository at this point in the history
* feat(fw): Add sha256 hash to fixtures

* feat: add hasher script

* changelog

* src/ethereum_test_tools/test_filling: add `_info/hash` to framework tests. (#16)

* fix(entry): Add short flags

* docs: add hasher to `verifying_changes.md`

---------

Co-authored-by: spencer <[email protected]>
  • Loading branch information
marioevz and spencer-tb authored Mar 1, 2024
1 parent 1594799 commit 7b9ccb9
Show file tree
Hide file tree
Showing 20 changed files with 249 additions and 52 deletions.
1 change: 1 addition & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ Test fixtures for use by clients are available for each release on the [Github r
-`Opcodes` enum now contains docstrings with each opcode description, including parameters and return values, which show up in many development environments ([#424](https://github.com/ethereum/execution-spec-tests/pull/424)) @ThreeHrSleep.
- 🔀 Locally calculate state root for the genesis blocks in the blockchain tests instead of calling t8n ([#450](https://github.com/ethereum/execution-spec-tests/pull/450)).
- 🐞 Fix bug that causes an exception during test collection because the fork parameter contains `None` ([#452](https://github.com/ethereum/execution-spec-tests/pull/452)).
- ✨ The `_info` field in the test fixtures now contains a `hash` field, which is the hash of the test fixture, and a `hasher` script has been added which prints and performs calculations on top of the hashes of all fixtures (see `hasher -h`) ([#454](https://github.com/ethereum/execution-spec-tests/pull/454)).

### 🔧 EVM Tools

Expand Down
35 changes: 35 additions & 0 deletions docs/writing_tests/verifying_changes.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,3 +88,38 @@ Verify:
```console
tox -e docs
```

### Verifying Fixture Changes

When writing a PR that modifies either the framework or test cases, it is important to verify that the changes do not cause any issues with the existing test cases.

All filled fixtures contain a `hash` field in the `_info` object, which is the hash of the json string of the fixture. This hash can be used to verify that the fixture has not changed.

The `hasher` command can be used to bulk-verify the hashes of all fixtures in a directory.

It has the following options:

| Flag | Description |
|--------------|-------------|
| `--files` / `-f` | Prints a single combined hash per each JSON fixture file recursively contained in a directory. |
| `--tests` / `-t` | Prints the hash of every single test vector in every JSON fixture file recursively contained in a directory. |
| `--root` / `-r` | Prints a single combined hash for all JSON fixture files recursively contained in a directory. |

For a quick comparison between two fixture directories, the `--root` option can be used and if the output matches, it means the fixtures in the directories are identical:

```console
hasher --root fixtures/
hasher --root fixtures_new/
```

If the output does not match, the `--files` option can be used to identify which files are different:

```console
diff <(hasher --files fixtures/) <(hasher --files fixtures_new/)
```

And the `--tests` option can be used for an even more granular comparison:

```console
diff <(hasher --tests fixtures/) <(hasher --tests fixtures_new/)
```
1 change: 1 addition & 0 deletions setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ console_scripts =
markdownlintcli2_soft_fail = entry_points.markdownlintcli2_soft_fail:main
create_whitelist_for_flake8_spelling = entry_points.create_whitelist_for_flake8_spelling:main
evm_bytes_to_python = entry_points.evm_bytes_to_python:main
hasher = entry_points.hasher:main

[options.extras_require]
test =
Expand Down
135 changes: 135 additions & 0 deletions src/entry_points/hasher.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
"""
Simple CLI tool to hash a directory of JSON fixtures.
"""

import argparse
import hashlib
import json
from dataclasses import dataclass, field
from enum import IntEnum, auto
from pathlib import Path
from typing import Dict, List, Optional


class HashableItemType(IntEnum):
"""
Represents the type of a hashable item.
"""

FOLDER = 0
FILE = auto()
TEST = auto()


@dataclass(kw_only=True)
class HashableItem:
"""
Represents an item that can be hashed containing other items that can be hashed as well.
"""

type: HashableItemType
parents: List[str] = field(default_factory=list)
root: Optional[bytes] = None
items: Optional[Dict[str, "HashableItem"]] = None

def hash(self) -> bytes:
"""
Return the hash of the item.
"""
if self.root is not None:
return self.root
if self.items is None:
raise ValueError("No items to hash")
all_hash_bytes = b""
for _, item in sorted(self.items.items()):
item_hash_bytes = item.hash()
all_hash_bytes += item_hash_bytes
return hashlib.sha256(all_hash_bytes).digest()

def print(
self, *, name: str, level: int = 0, print_type: Optional[HashableItemType] = None
) -> None:
"""
Print the hash of the item and sub-items.
"""
next_level = level
print_name = name
if level == 0 and self.parents:
separator = "::" if self.type == HashableItemType.TEST else "/"
print_name = f"{'/'.join(self.parents)}{separator}{name}"
if print_type is None or self.type >= print_type:
next_level += 1
print(f"{' ' * level}{print_name}: 0x{self.hash().hex()}")

if self.items is not None:
for key, item in sorted(self.items.items()):
item.print(name=key, level=next_level, print_type=print_type)

@classmethod
def from_json_file(cls, *, file_path: Path, parents: List[str]) -> "HashableItem":
"""
Create a hashable item from a JSON file.
"""
items = {}
with file_path.open("r") as f:
data = json.load(f)
for key, item in sorted(data.items()):
assert isinstance(item, dict), f"Expected dict, got {type(item)}"
assert "_info" in item, f"Expected _info in {key}"
assert "hash" in item["_info"], f"Expected hash in {key}"
assert isinstance(
item["_info"]["hash"], str
), f"Expected hash to be a string in {key}, got {type(item['_info']['hash'])}"
item_hash_bytes = bytes.fromhex(item["_info"]["hash"][2:])
items[key] = cls(
type=HashableItemType.TEST,
root=item_hash_bytes,
parents=parents + [file_path.name],
)
return cls(type=HashableItemType.FILE, items=items, parents=parents)

@classmethod
def from_folder(cls, *, folder_path: Path, parents: List[str] = []) -> "HashableItem":
"""
Create a hashable item from a folder.
"""
items = {}
for file_path in sorted(folder_path.iterdir()):
if file_path.is_file() and file_path.suffix == ".json":
item = cls.from_json_file(
file_path=file_path, parents=parents + [folder_path.name]
)
items[file_path.name] = item
elif file_path.is_dir():
item = cls.from_folder(folder_path=file_path, parents=parents + [folder_path.name])
items[file_path.name] = item
return cls(type=HashableItemType.FOLDER, items=items, parents=parents)


def main() -> None:
"""
Main function.
"""
parser = argparse.ArgumentParser(description="Hash folders of JSON fixtures.")

parser.add_argument("folder_path", type=Path, help="The path to the JSON fixtures directory")
parser.add_argument("--files", "-f", action="store_true", help="Print hash of files")
parser.add_argument("--tests", "-t", action="store_true", help="Print hash of tests")
parser.add_argument("--root", "-r", action="store_true", help="Only print hash of root folder")

args = parser.parse_args()

item = HashableItem.from_folder(folder_path=args.folder_path)

if args.root:
print(f"0x{item.hash().hex()}")
return

print_type: Optional[HashableItemType] = None

if args.files:
print_type = HashableItemType.FILE
elif args.tests:
print_type = HashableItemType.TEST

item.print(name=args.folder_path.name, print_type=print_type)
4 changes: 3 additions & 1 deletion src/ethereum_test_tools/common/json.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""
JSON encoding and decoding for Ethereum types.
"""

import json
from abc import ABC, abstractmethod
from dataclasses import dataclass
Expand Down Expand Up @@ -112,8 +113,9 @@ def default(self, obj: Any) -> Any:
for object_field in fields(obj):
field_name = object_field.name
metadata = object_field.metadata
if not metadata:
continue
value = getattr(obj, field_name)
assert metadata is not None, f"Field {field_name} has no metadata"
field_settings = metadata.get("json_encoder")
assert isinstance(field_settings, self.Field), (
f"Field {field_name} has invalid json_encoder " f"metadata: {field_settings}"
Expand Down
20 changes: 18 additions & 2 deletions src/ethereum_test_tools/spec/base/base_test.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
"""
Base test class and helper functions for Ethereum state and blockchain tests.
"""

import hashlib
import json
from abc import abstractmethod
from dataclasses import dataclass, field
from itertools import count
Expand All @@ -15,6 +18,7 @@
from ...common.conversions import to_hex
from ...common.json import JSONEncoder
from ...common.json import field as json_field
from ...common.json import to_json
from ...reference_spec.reference_spec import ReferenceSpec


Expand Down Expand Up @@ -87,6 +91,8 @@ class BaseFixture:
),
)

_json: Optional[Dict[str, Any]] = None

def fill_info(
self,
t8n: TransitionTool,
Expand All @@ -101,12 +107,22 @@ def fill_info(
if ref_spec is not None:
ref_spec.write_info(self.info)

@abstractmethod
def __post_init__(self):
"""
Post init hook to convert to JSON after instantiation.
"""
self._json = to_json(self)
json_str = json.dumps(self._json, sort_keys=True, separators=(",", ":"))
h = hashlib.sha256(json_str.encode("utf-8")).hexdigest()
self.info["hash"] = f"0x{h}"

def to_json(self) -> Dict[str, Any]:
"""
Convert to JSON.
"""
pass
assert self._json is not None, "Fixture not initialized"
self._json["_info"] = self.info
return self._json

@classmethod
@abstractmethod
Expand Down
23 changes: 2 additions & 21 deletions src/ethereum_test_tools/spec/blockchain/types.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""
BlockchainTest types
"""

import json
from copy import copy, deepcopy
from dataclasses import dataclass, fields, replace
Expand All @@ -26,7 +27,7 @@
)
from ...common.constants import AddrAA, EmptyOmmersRoot, EngineAPIError
from ...common.conversions import BytesConvertible, FixedSizeBytesConvertible, NumberConvertible
from ...common.json import JSONEncoder, field, to_json
from ...common.json import JSONEncoder, field
from ...common.types import (
Account,
Alloc,
Expand Down Expand Up @@ -1017,26 +1018,6 @@ class FixtureCommon(BaseFixture):
name="network",
),
)
_json: Dict[str, Any] | None = field(
default=None,
json_encoder=JSONEncoder.Field(
skip=True,
),
)

def __post_init__(self):
"""
Post init hook to convert to JSON after instantiation.
"""
self._json = to_json(self)

def to_json(self) -> Dict[str, Any]:
"""
Convert to JSON.
"""
assert self._json is not None, "Fixture not initialized"
self._json["_info"] = self.info
return self._json

@classmethod
def collect_into_file(cls, fd: TextIO, fixtures: Dict[str, "BaseFixture"]):
Expand Down
24 changes: 2 additions & 22 deletions src/ethereum_test_tools/spec/state/types.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""
StateTest types
"""

import json
from dataclasses import dataclass, fields
from pathlib import Path
Expand All @@ -10,7 +11,7 @@

from ...common.base_types import Address, Bytes, Hash, HexNumber, ZeroPaddedHexNumber
from ...common.conversions import BytesConvertible, FixedSizeBytesConvertible, NumberConvertible
from ...common.json import JSONEncoder, field, to_json
from ...common.json import JSONEncoder, field
from ...common.types import AccessList, Alloc, Environment, Transaction
from ...exceptions import ExceptionList, TransactionException
from ..base.base_test import BaseFixture
Expand Down Expand Up @@ -287,27 +288,6 @@ class Fixture(BaseFixture):
),
)

_json: Dict[str, Any] | None = field(
default=None,
json_encoder=JSONEncoder.Field(
skip=True,
),
)

def __post_init__(self):
"""
Post init hook to convert to JSON after instantiation.
"""
self._json = to_json(self)

def to_json(self) -> Dict[str, Any]:
"""
Convert to JSON.
"""
assert self._json is not None, "Fixture not initialized"
self._json["_info"] = self.info
return self._json

@classmethod
def collect_into_file(cls, fd: TextIO, fixtures: Dict[str, "BaseFixture"]):
"""
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
{
"solc=0.8.20": {
"000/my_blockchain_test/London": {
"_info": {
"hash": "0x2c3bb32517b56a755b7433abd36d4a6b761c1d79d9fbed7dac4ea532eb739c11"
},
"network": "London",
"genesisRLP": "0xf90200f901fba00000000000000000000000000000000000000000000000000000000000000000a01dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347940000000000000000000000000000000000000000a089a5be1d3306f6f05b42678ef13ac3dbc37bef9a2a80862c21eb22eee29194c2a056e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421a056e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421b9010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000830200008088016345785d8a0000808000a000000000000000000000000000000000000000000000000000000000000000008800000000000000008203e8c0c0",
"genesisBlockHeader": {
Expand Down Expand Up @@ -541,6 +544,9 @@
},
"solc=padding_version": {
"000/my_blockchain_test/London": {
"_info": {
"hash": "0x0aede6c01648a4a89633be93ad0dcc5ddb48dc27c38cddeac0629edf5ea2b7b5"
},
"network": "London",
"genesisRLP": "0xf90200f901fba00000000000000000000000000000000000000000000000000000000000000000a01dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347940000000000000000000000000000000000000000a0de1557ffdf9765e61095937bf835742ca427008f33714bee743010ab2d1e0ba6a056e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421a056e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421b9010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000830200008088016345785d8a0000808000a000000000000000000000000000000000000000000000000000000000000000008800000000000000008203e8c0c0",
"genesisBlockHeader": {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
{
"solc=0.8.20": {
"000/my_blockchain_test/London": {
"_info": {
"hash": "0xf3750dd67158fb66466c4deb195073bb5a4621f3b3ee315c9d3fa443d7d8c445"
},
"network": "London",
"genesisRLP": "0xf90200f901fba00000000000000000000000000000000000000000000000000000000000000000a01dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347940000000000000000000000000000000000000000a089a5be1d3306f6f05b42678ef13ac3dbc37bef9a2a80862c21eb22eee29194c2a056e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421a056e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421b9010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000830200008088016345785d8a0000808000a000000000000000000000000000000000000000000000000000000000000000008800000000000000008203e8c0c0",
"genesisBlockHeader": {
Expand Down Expand Up @@ -419,6 +422,9 @@
},
"solc=padding_version": {
"000/my_blockchain_test/London": {
"_info": {
"hash": "0xb31303cc3ecdd1cac8b0669e43f70e1f8784aa8659f457ed4e1654935c6e986b"
},
"blocks": [
{
"rlp": "0xf9026ef901fea0c552af8a2644e24df2f54d14aa70f207146dda49b746cc2e0af88e185f043d2ea01dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d4934794ba5e000000000000000000000000000000000000a06bbd44292c9016cf53472d8ef579a1805a9008b898c5f159248ed106532b667ba0586f963eea0fb4726f0f91f895f2aa5d67bffb5207a529b40d781244a0c7017ba029b0562f7140574dd0d50dee8a271b22e1a0a7b78fca58f7c60370d8317ba2a9b9010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000830200000188016345785d8a0000830155340c80a0000000000000000000000000000000000000000000000000000000000000000088000000000000000082036bf86ab86802f8650180018203e8830f424094cccccccccccccccccccccccccccccccccccccccc8001c080a03351b6993208fc7b03fd770c8c06440cfb0d75b29aafee0a4c64c8ba20a80e58a067817fdb3058e75c5d26e51a33d1e338346bc7d406e115447a4bb5f7ab01625bc0",
Expand Down
Loading

0 comments on commit 7b9ccb9

Please sign in to comment.