Skip to content

Commit 961539a

Browse files
committed
feat(consume): consume direct using evmone-statetest
1 parent 9f96fad commit 961539a

File tree

3 files changed

+195
-5
lines changed

3 files changed

+195
-5
lines changed

docs/running_tests/consume/direct.md

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,9 @@ uv run consume direct --bin=<evm-binary> [OPTIONS]
1313

1414
Currently, only the following clients can be used with `consume direct`:
1515

16-
- go-ethereum `statetest` and `blocktest`.
17-
- Nethermind `nethtest`.
16+
- go-ethereum `statetest` and `blocktest`
17+
- Nethermind `nethtest`
18+
- evmone `evmone-statetest`
1819

1920
## Advantages
2021

@@ -24,7 +25,7 @@ uv run consume direct --bin=<evm-binary> [OPTIONS]
2425

2526
## Limitations
2627

27-
- **Limited client support**: Only go-ethereum and Nethermind.
28+
- **Limited client support**: Only go-ethereum, Nethermind and (partially) evmone
2829
- **Module scope**: Tests EVM, respectively block import, in isolation, not full client behavior.
2930
- **Interface dependency**: Requires client-specific test interfaces.
3031

@@ -42,6 +43,12 @@ or Nethermind:
4243
uv run consume direct --input ./fixtures -m state_test --bin=nethtest
4344
```
4445

46+
or evmone:
47+
48+
```bash
49+
uv run consume direct --input ./fixtures -m state_test --bin=evmone-statetest
50+
```
51+
4552
Run fixtures in the blockchain test format for the Prague fork:
4653

4754
```bash

src/ethereum_clis/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
)
1010
from .clis.besu import BesuTransitionTool
1111
from .clis.ethereumjs import EthereumJSTransitionTool
12-
from .clis.evmone import EvmoneExceptionMapper, EvmOneTransitionTool
12+
from .clis.evmone import EvmoneExceptionMapper, EvmOneFixtureConsumer, EvmOneTransitionTool
1313
from .clis.execution_specs import ExecutionSpecsTransitionTool
1414
from .clis.geth import GethFixtureConsumer, GethTransitionTool
1515
from .clis.nethermind import Nethtest, NethtestFixtureConsumer
@@ -28,6 +28,7 @@
2828
"EthereumJSTransitionTool",
2929
"EvmoneExceptionMapper",
3030
"EvmOneTransitionTool",
31+
"EvmOneFixtureConsumer",
3132
"ExecutionSpecsTransitionTool",
3233
"FixtureConsumerTool",
3334
"GethFixtureConsumer",

src/ethereum_clis/clis/evmone.py

Lines changed: 183 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,26 @@
11
"""Evmone Transition tool interface."""
22

3+
import json
34
import re
5+
import shlex
6+
import shutil
7+
import subprocess
8+
import tempfile
9+
import textwrap
10+
from functools import cache
411
from pathlib import Path
5-
from typing import ClassVar, Dict, Optional
12+
from typing import Any, ClassVar, Dict, List, Optional
613

14+
from ethereum_clis.file_utils import dump_files_to_directory
15+
from ethereum_clis.fixture_consumer_tool import FixtureConsumerTool
716
from ethereum_test_exceptions import (
817
EOFException,
918
ExceptionBase,
1019
ExceptionMapper,
1120
TransactionException,
1221
)
22+
from ethereum_test_fixtures.base import FixtureFormat
23+
from ethereum_test_fixtures.state import StateFixture
1324
from ethereum_test_forks import Fork
1425

1526
from ..transition_tool import TransitionTool
@@ -43,6 +54,177 @@ def is_fork_supported(self, fork: Fork) -> bool:
4354
return True
4455

4556

57+
class EvmOneFixtureConsumer(
58+
FixtureConsumerTool,
59+
fixture_formats=[StateFixture],
60+
):
61+
"""Evmone's implementation of the fixture consumer."""
62+
63+
default_binary = Path("evmone-statetest")
64+
detect_binary_pattern = re.compile(r"^evmone-statetest\b")
65+
version_flag: str = "--version"
66+
67+
binary: Path
68+
cached_version: Optional[str] = None
69+
70+
def __init__(
71+
self,
72+
binary: Optional[Path] = None,
73+
trace: bool = False,
74+
):
75+
"""Initialize the EvmOneFixtureConsumer class."""
76+
self.binary = binary if binary else self.default_binary
77+
self._info_metadata: Optional[Dict[str, Any]] = {}
78+
79+
def _run_command(self, command: List[str]) -> subprocess.CompletedProcess:
80+
try:
81+
return subprocess.run(
82+
command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True
83+
)
84+
except subprocess.CalledProcessError as e:
85+
raise Exception("Command failed with non-zero status.") from e
86+
except Exception as e:
87+
raise Exception("Unexpected exception calling evm tool.") from e
88+
89+
# TODO: copied from geth.py, needs to be deduplicated, but nethermind.py also has its version
90+
def _consume_debug_dump(
91+
self,
92+
command: List[str],
93+
result: subprocess.CompletedProcess,
94+
fixture_path: Path,
95+
debug_output_path: Path,
96+
):
97+
# our assumption is that each command element is a string
98+
assert all(isinstance(x, str) for x in command), (
99+
f"Not all elements of 'command' list are strings: {command}"
100+
)
101+
assert len(command) > 0
102+
103+
# replace last value with debug fixture path
104+
debug_fixture_path = str(debug_output_path / "fixtures.json")
105+
command[-1] = debug_fixture_path
106+
107+
# ensure that flags with spaces are wrapped in double-quotes
108+
consume_direct_call = " ".join(shlex.quote(arg) for arg in command)
109+
110+
consume_direct_script = textwrap.dedent(
111+
f"""\
112+
#!/bin/bash
113+
{consume_direct_call}
114+
"""
115+
)
116+
dump_files_to_directory(
117+
str(debug_output_path),
118+
{
119+
"consume_direct_args.py": command,
120+
"consume_direct_returncode.txt": result.returncode,
121+
"consume_direct_stdout.txt": result.stdout,
122+
"consume_direct_stderr.txt": result.stderr,
123+
"consume_direct.sh+x": consume_direct_script,
124+
},
125+
)
126+
shutil.copyfile(fixture_path, debug_fixture_path)
127+
128+
@cache # noqa
129+
def consume_state_test_file(
130+
self,
131+
fixture_path: Path,
132+
debug_output_path: Optional[Path] = None,
133+
) -> Dict[str, Any]:
134+
"""
135+
Consume an entire state test file.
136+
137+
The `evmone-statetest` will always execute all the tests contained in a file without the
138+
possibility of selecting a single test, so this function is cached in order to only call
139+
the command once and `consume_state_test` can simply select the result that
140+
was requested.
141+
"""
142+
global_options: List[str] = []
143+
if debug_output_path:
144+
global_options += ["--trace"]
145+
146+
with tempfile.NamedTemporaryFile() as tempfile_json:
147+
# `evmone` uses `gtest` and generates JSON output to a file,
148+
# c.f. https://google.github.io/googletest/advanced.html#generating-a-json-report
149+
# see there for the JSON schema.
150+
global_options += ["--gtest_output=json:{}".format(tempfile_json.name)]
151+
command = [str(self.binary)] + global_options + [str(fixture_path)]
152+
result = self._run_command(command)
153+
154+
if result.returncode not in [0, 1]:
155+
raise Exception(
156+
f"Unexpected exit code:\n{' '.join(command)}\n\n Error:\n{result.stderr}"
157+
)
158+
159+
try:
160+
output_data = json.load(tempfile_json)
161+
except json.JSONDecodeError as e:
162+
raise Exception(f"Failed to parse JSON output from evmone-statetest: {e}") from e
163+
164+
if debug_output_path:
165+
self._consume_debug_dump(command, result, fixture_path, debug_output_path)
166+
167+
return output_data
168+
169+
def _failure_msg(self, file_results: Dict[str, Any]) -> str:
170+
# Assumes only one test has run and there has been a failure, as asserted before.
171+
failures = file_results["testsuites"][0]["testsuite"][0]["failures"]
172+
return ", ".join([f["failure"] for f in failures])
173+
174+
def consume_state_test(
175+
self,
176+
fixture_path: Path,
177+
fixture_name: Optional[str] = None,
178+
debug_output_path: Optional[Path] = None,
179+
):
180+
"""
181+
Consume a single state test.
182+
183+
Uses the cached result from `consume_state_test_file` in order to not call the command
184+
every time an select a single result from there.
185+
"""
186+
file_results = self.consume_state_test_file(
187+
fixture_path=fixture_path,
188+
debug_output_path=debug_output_path,
189+
)
190+
if not fixture_name:
191+
fixture_hint = fixture_path.stem
192+
else:
193+
fixture_hint = fixture_name
194+
assert file_results["tests"] == 1, f"Multiple tests ran for {fixture_hint}"
195+
assert file_results["disabled"] == 0, f"Disabled tests for {fixture_hint}"
196+
assert file_results["errors"] == 0, f"Errors during test for {fixture_hint}"
197+
assert file_results["failures"] == 0, (
198+
f"Failures for {fixture_hint}: {self._failure_msg(file_results)}"
199+
)
200+
201+
test_name = file_results["testsuites"][0]["testsuite"][0]["name"]
202+
assert test_name == fixture_path.stem, (
203+
f"Test name mismatch, expected {fixture_path.stem}, got {test_name}"
204+
)
205+
206+
def consume_fixture(
207+
self,
208+
fixture_format: FixtureFormat,
209+
fixture_path: Path,
210+
fixture_name: Optional[str] = None,
211+
debug_output_path: Optional[Path] = None,
212+
):
213+
"""Execute the appropriate geth fixture consumer for the fixture at `fixture_path`."""
214+
# if fixture_format == BlockchainFixture:
215+
# raise "zonk"
216+
if fixture_format == StateFixture:
217+
self.consume_state_test(
218+
fixture_path=fixture_path,
219+
fixture_name=fixture_name,
220+
debug_output_path=debug_output_path,
221+
)
222+
else:
223+
raise Exception(
224+
f"Fixture format {fixture_format.format_name} not supported by {self.binary}"
225+
)
226+
227+
46228
class EvmoneExceptionMapper(ExceptionMapper):
47229
"""Translate between EEST exceptions and error strings returned by Evmone."""
48230

0 commit comments

Comments
 (0)