|
1 | 1 | """Evmone Transition tool interface.""" |
2 | 2 |
|
| 3 | +import json |
3 | 4 | import re |
| 5 | +import shlex |
| 6 | +import shutil |
| 7 | +import subprocess |
| 8 | +import tempfile |
| 9 | +import textwrap |
| 10 | +from functools import cache |
4 | 11 | from pathlib import Path |
5 | | -from typing import ClassVar, Dict, Optional |
| 12 | +from typing import Any, ClassVar, Dict, List, Optional |
6 | 13 |
|
| 14 | +from ethereum_clis.file_utils import dump_files_to_directory |
| 15 | +from ethereum_clis.fixture_consumer_tool import FixtureConsumerTool |
7 | 16 | from ethereum_test_exceptions import ( |
8 | 17 | EOFException, |
9 | 18 | ExceptionBase, |
10 | 19 | ExceptionMapper, |
11 | 20 | TransactionException, |
12 | 21 | ) |
| 22 | +from ethereum_test_fixtures.base import FixtureFormat |
| 23 | +from ethereum_test_fixtures.state import StateFixture |
13 | 24 | from ethereum_test_forks import Fork |
14 | 25 |
|
15 | 26 | from ..transition_tool import TransitionTool |
@@ -43,6 +54,180 @@ def is_fork_supported(self, fork: Fork) -> bool: |
43 | 54 | return True |
44 | 55 |
|
45 | 56 |
|
| 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 |
| 90 | + # also has its version |
| 91 | + def _consume_debug_dump( |
| 92 | + self, |
| 93 | + command: List[str], |
| 94 | + result: subprocess.CompletedProcess, |
| 95 | + fixture_path: Path, |
| 96 | + debug_output_path: Path, |
| 97 | + ): |
| 98 | + # our assumption is that each command element is a string |
| 99 | + assert all(isinstance(x, str) for x in command), ( |
| 100 | + f"Not all elements of 'command' list are strings: {command}" |
| 101 | + ) |
| 102 | + assert len(command) > 0 |
| 103 | + |
| 104 | + # replace last value with debug fixture path |
| 105 | + debug_fixture_path = str(debug_output_path / "fixtures.json") |
| 106 | + command[-1] = debug_fixture_path |
| 107 | + |
| 108 | + # ensure that flags with spaces are wrapped in double-quotes |
| 109 | + consume_direct_call = " ".join(shlex.quote(arg) for arg in command) |
| 110 | + |
| 111 | + consume_direct_script = textwrap.dedent( |
| 112 | + f"""\ |
| 113 | + #!/bin/bash |
| 114 | + {consume_direct_call} |
| 115 | + """ |
| 116 | + ) |
| 117 | + dump_files_to_directory( |
| 118 | + str(debug_output_path), |
| 119 | + { |
| 120 | + "consume_direct_args.py": command, |
| 121 | + "consume_direct_returncode.txt": result.returncode, |
| 122 | + "consume_direct_stdout.txt": result.stdout, |
| 123 | + "consume_direct_stderr.txt": result.stderr, |
| 124 | + "consume_direct.sh+x": consume_direct_script, |
| 125 | + }, |
| 126 | + ) |
| 127 | + shutil.copyfile(fixture_path, debug_fixture_path) |
| 128 | + |
| 129 | + @cache # noqa |
| 130 | + def consume_state_test_file( |
| 131 | + self, |
| 132 | + fixture_path: Path, |
| 133 | + debug_output_path: Optional[Path] = None, |
| 134 | + ) -> Dict[str, Any]: |
| 135 | + """ |
| 136 | + Consume an entire state test file. |
| 137 | +
|
| 138 | + The `evmone-statetest` will always execute all the tests contained in a |
| 139 | + file without the possibility of selecting a single test, so this |
| 140 | + function is cached in order to only call the command once and |
| 141 | + `consume_state_test` can simply select the result that was requested. |
| 142 | + """ |
| 143 | + global_options: List[str] = [] |
| 144 | + if debug_output_path: |
| 145 | + global_options += ["--trace"] |
| 146 | + |
| 147 | + with tempfile.NamedTemporaryFile() as tempfile_json: |
| 148 | + # `evmone` uses `gtest` and generates JSON output to a file, |
| 149 | + # c.f. https://google.github.io/googletest/advanced.html#generating-a-json-report |
| 150 | + # see there for the JSON schema. |
| 151 | + global_options += ["--gtest_output=json:{}".format(tempfile_json.name)] |
| 152 | + command = [str(self.binary)] + global_options + [str(fixture_path)] |
| 153 | + result = self._run_command(command) |
| 154 | + |
| 155 | + if result.returncode not in [0, 1]: |
| 156 | + raise Exception( |
| 157 | + f"Unexpected exit code:\n{' '.join(command)}\n\n Error:\n{result.stderr}" |
| 158 | + ) |
| 159 | + |
| 160 | + try: |
| 161 | + output_data = json.load(tempfile_json) |
| 162 | + except json.JSONDecodeError as e: |
| 163 | + raise Exception(f"Failed to parse JSON output from evmone-statetest: {e}") from e |
| 164 | + |
| 165 | + if debug_output_path: |
| 166 | + self._consume_debug_dump(command, result, fixture_path, debug_output_path) |
| 167 | + |
| 168 | + return output_data |
| 169 | + |
| 170 | + def _failure_msg(self, file_results: Dict[str, Any]) -> str: |
| 171 | + # Assumes only one test has run and there has been a failure, |
| 172 | + # as asserted before. |
| 173 | + failures = file_results["testsuites"][0]["testsuite"][0]["failures"] |
| 174 | + return ", ".join([f["failure"] for f in failures]) |
| 175 | + |
| 176 | + def consume_state_test( |
| 177 | + self, |
| 178 | + fixture_path: Path, |
| 179 | + fixture_name: Optional[str] = None, |
| 180 | + debug_output_path: Optional[Path] = None, |
| 181 | + ): |
| 182 | + """ |
| 183 | + Consume a single state test. |
| 184 | +
|
| 185 | + Uses the cached result from `consume_state_test_file` in order to not |
| 186 | + call the command every time an select a single result from there. |
| 187 | + """ |
| 188 | + file_results = self.consume_state_test_file( |
| 189 | + fixture_path=fixture_path, |
| 190 | + debug_output_path=debug_output_path, |
| 191 | + ) |
| 192 | + if not fixture_name: |
| 193 | + fixture_hint = fixture_path.stem |
| 194 | + else: |
| 195 | + fixture_hint = fixture_name |
| 196 | + assert file_results["tests"] == 1, f"Multiple tests ran for {fixture_hint}" |
| 197 | + assert file_results["disabled"] == 0, f"Disabled tests for {fixture_hint}" |
| 198 | + assert file_results["errors"] == 0, f"Errors during test for {fixture_hint}" |
| 199 | + assert file_results["failures"] == 0, ( |
| 200 | + f"Failures for {fixture_hint}: {self._failure_msg(file_results)}" |
| 201 | + ) |
| 202 | + |
| 203 | + test_name = file_results["testsuites"][0]["testsuite"][0]["name"] |
| 204 | + assert test_name == fixture_path.stem, ( |
| 205 | + f"Test name mismatch, expected {fixture_path.stem}, got {test_name}" |
| 206 | + ) |
| 207 | + |
| 208 | + def consume_fixture( |
| 209 | + self, |
| 210 | + fixture_format: FixtureFormat, |
| 211 | + fixture_path: Path, |
| 212 | + fixture_name: Optional[str] = None, |
| 213 | + debug_output_path: Optional[Path] = None, |
| 214 | + ): |
| 215 | + """ |
| 216 | + Execute the appropriate geth fixture consumer for the fixture at |
| 217 | + `fixture_path`. |
| 218 | + """ |
| 219 | + if fixture_format == StateFixture: |
| 220 | + self.consume_state_test( |
| 221 | + fixture_path=fixture_path, |
| 222 | + fixture_name=fixture_name, |
| 223 | + debug_output_path=debug_output_path, |
| 224 | + ) |
| 225 | + else: |
| 226 | + raise Exception( |
| 227 | + f"Fixture format {fixture_format.format_name} not supported by {self.binary}" |
| 228 | + ) |
| 229 | + |
| 230 | + |
46 | 231 | class EvmoneExceptionMapper(ExceptionMapper): |
47 | 232 | """ |
48 | 233 | Translate between EEST exceptions and error strings returned by Evmone. |
|
0 commit comments