|
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 | +import pytest |
| 15 | + |
| 16 | +from ethereum_clis.file_utils import dump_files_to_directory |
| 17 | +from ethereum_clis.fixture_consumer_tool import FixtureConsumerTool |
7 | 18 | from ethereum_test_exceptions import (
|
8 | 19 | EOFException,
|
9 | 20 | ExceptionBase,
|
10 | 21 | ExceptionMapper,
|
11 | 22 | TransactionException,
|
12 | 23 | )
|
| 24 | +from ethereum_test_fixtures.base import FixtureFormat |
| 25 | +from ethereum_test_fixtures.blockchain import BlockchainFixture |
| 26 | +from ethereum_test_fixtures.state import StateFixture |
13 | 27 | from ethereum_test_forks import Fork
|
14 | 28 |
|
15 | 29 | from ..transition_tool import TransitionTool
|
@@ -43,6 +57,236 @@ def is_fork_supported(self, fork: Fork) -> bool:
|
43 | 57 | return True
|
44 | 58 |
|
45 | 59 |
|
| 60 | +class EvmoneFixtureConsumerCommon: |
| 61 | + """Common functionality for Evmone fixture consumers.""" |
| 62 | + |
| 63 | + binary: Path |
| 64 | + version_flag: str = "--version" |
| 65 | + |
| 66 | + cached_version: Optional[str] = None |
| 67 | + |
| 68 | + def __init__( |
| 69 | + self, |
| 70 | + trace: bool = False, |
| 71 | + ): |
| 72 | + """Initialize the EvmoneFixtureConsumerCommon class.""" |
| 73 | + self._info_metadata: Optional[Dict[str, Any]] = {} |
| 74 | + |
| 75 | + def _run_command(self, command: List[str]) -> subprocess.CompletedProcess: |
| 76 | + try: |
| 77 | + return subprocess.run( |
| 78 | + command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True |
| 79 | + ) |
| 80 | + except subprocess.CalledProcessError as e: |
| 81 | + raise Exception("Command failed with non-zero status.") from e |
| 82 | + except Exception as e: |
| 83 | + raise Exception("Unexpected exception calling evm tool.") from e |
| 84 | + |
| 85 | + # TODO: copied from geth.py, needs to be deduplicated, but nethermind.py |
| 86 | + # also has its version |
| 87 | + def _consume_debug_dump( |
| 88 | + self, |
| 89 | + command: List[str], |
| 90 | + result: subprocess.CompletedProcess, |
| 91 | + fixture_path: Path, |
| 92 | + debug_output_path: Path, |
| 93 | + ): |
| 94 | + # our assumption is that each command element is a string |
| 95 | + assert all(isinstance(x, str) for x in command), ( |
| 96 | + f"Not all elements of 'command' list are strings: {command}" |
| 97 | + ) |
| 98 | + assert len(command) > 0 |
| 99 | + |
| 100 | + # replace last value with debug fixture path |
| 101 | + debug_fixture_path = str(debug_output_path / "fixtures.json") |
| 102 | + command[-1] = debug_fixture_path |
| 103 | + |
| 104 | + # ensure that flags with spaces are wrapped in double-quotes |
| 105 | + consume_direct_call = " ".join(shlex.quote(arg) for arg in command) |
| 106 | + |
| 107 | + consume_direct_script = textwrap.dedent( |
| 108 | + f"""\ |
| 109 | + #!/bin/bash |
| 110 | + {consume_direct_call} |
| 111 | + """ |
| 112 | + ) |
| 113 | + dump_files_to_directory( |
| 114 | + str(debug_output_path), |
| 115 | + { |
| 116 | + "consume_direct_args.py": command, |
| 117 | + "consume_direct_returncode.txt": result.returncode, |
| 118 | + "consume_direct_stdout.txt": result.stdout, |
| 119 | + "consume_direct_stderr.txt": result.stderr, |
| 120 | + "consume_direct.sh+x": consume_direct_script, |
| 121 | + }, |
| 122 | + ) |
| 123 | + shutil.copyfile(fixture_path, debug_fixture_path) |
| 124 | + |
| 125 | + def _skip_message(self, fixture_format: FixtureFormat) -> str: |
| 126 | + return f"Fixture format {fixture_format.format_name} not supported by {self.binary}" |
| 127 | + |
| 128 | + @cache # noqa |
| 129 | + def consume_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 or blockchain test file. |
| 136 | +
|
| 137 | + The `evmone-...test` will always execute all the tests contained in a |
| 138 | + file without the possibility of selecting a single test, so this |
| 139 | + function is cached in order to only call the command once and |
| 140 | + `consume_test` can simply select the result that 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( |
| 163 | + f"Failed to parse JSON output from evmone-state/blockchaintest: {e}" |
| 164 | + ) from e |
| 165 | + |
| 166 | + if debug_output_path: |
| 167 | + self._consume_debug_dump(command, result, fixture_path, debug_output_path) |
| 168 | + |
| 169 | + return output_data |
| 170 | + |
| 171 | + def _failure_msg(self, file_results: Dict[str, Any]) -> str: |
| 172 | + # Assumes only one test has run and there has been a failure, |
| 173 | + # as asserted before. |
| 174 | + failures = file_results["testsuites"][0]["testsuite"][0]["failures"] |
| 175 | + return ", ".join([f["failure"] for f in failures]) |
| 176 | + |
| 177 | + def consume_test( |
| 178 | + self, |
| 179 | + fixture_path: Path, |
| 180 | + fixture_name: Optional[str] = None, |
| 181 | + debug_output_path: Optional[Path] = None, |
| 182 | + ): |
| 183 | + """ |
| 184 | + Consume a single state or blockchain test. |
| 185 | +
|
| 186 | + Uses the cached result from `consume_test_file` in order to not |
| 187 | + call the command every time an select a single result from there. |
| 188 | + """ |
| 189 | + file_results = self.consume_test_file( |
| 190 | + fixture_path=fixture_path, |
| 191 | + debug_output_path=debug_output_path, |
| 192 | + ) |
| 193 | + if not fixture_name: |
| 194 | + fixture_hint = fixture_path.stem |
| 195 | + else: |
| 196 | + fixture_hint = fixture_name |
| 197 | + assert file_results["tests"] == 1, f"Multiple tests ran for {fixture_hint}" |
| 198 | + assert file_results["disabled"] == 0, f"Disabled tests for {fixture_hint}" |
| 199 | + assert file_results["errors"] == 0, f"Errors during test for {fixture_hint}" |
| 200 | + assert file_results["failures"] == 0, ( |
| 201 | + f"Failures for {fixture_hint}: {self._failure_msg(file_results)}" |
| 202 | + ) |
| 203 | + |
| 204 | + test_name = file_results["testsuites"][0]["testsuite"][0]["name"] |
| 205 | + assert test_name == fixture_path.stem, ( |
| 206 | + f"Test name mismatch, expected {fixture_path.stem}, got {test_name}" |
| 207 | + ) |
| 208 | + |
| 209 | + |
| 210 | +class EvmOneStateFixtureConsumer( |
| 211 | + EvmoneFixtureConsumerCommon, |
| 212 | + FixtureConsumerTool, |
| 213 | + fixture_formats=[StateFixture], |
| 214 | +): |
| 215 | + """Evmone's implementation of the fixture consumer for state tests.""" |
| 216 | + |
| 217 | + default_binary = Path("evmone-statetest") |
| 218 | + detect_binary_pattern = re.compile(r"^evmone-statetest\b") |
| 219 | + |
| 220 | + def __init__( |
| 221 | + self, |
| 222 | + binary: Optional[Path] = None, |
| 223 | + trace: bool = False, |
| 224 | + ): |
| 225 | + """Initialize the EvmOneStateFixtureConsumer class.""" |
| 226 | + self.binary = binary if binary else self.default_binary |
| 227 | + super().__init__(trace=trace) |
| 228 | + |
| 229 | + def consume_fixture( |
| 230 | + self, |
| 231 | + fixture_format: FixtureFormat, |
| 232 | + fixture_path: Path, |
| 233 | + fixture_name: Optional[str] = None, |
| 234 | + debug_output_path: Optional[Path] = None, |
| 235 | + ): |
| 236 | + """ |
| 237 | + Execute the appropriate fixture consumer for the fixture at |
| 238 | + `fixture_path`. |
| 239 | + """ |
| 240 | + if fixture_format == StateFixture: |
| 241 | + self.consume_test( |
| 242 | + fixture_path=fixture_path, |
| 243 | + fixture_name=fixture_name, |
| 244 | + debug_output_path=debug_output_path, |
| 245 | + ) |
| 246 | + else: |
| 247 | + pytest.skip(self._skip_message(fixture_format)) |
| 248 | + |
| 249 | + |
| 250 | +class EvmOneBlockchainFixtureConsumer( |
| 251 | + EvmoneFixtureConsumerCommon, |
| 252 | + FixtureConsumerTool, |
| 253 | + fixture_formats=[BlockchainFixture], |
| 254 | +): |
| 255 | + """Evmone's implementation of the fixture consumer for blockchain tests.""" |
| 256 | + |
| 257 | + default_binary = Path("evmone-blockchaintest") |
| 258 | + detect_binary_pattern = re.compile(r"^evmone-blockchaintest\b") |
| 259 | + |
| 260 | + def __init__( |
| 261 | + self, |
| 262 | + binary: Optional[Path] = None, |
| 263 | + trace: bool = False, |
| 264 | + ): |
| 265 | + """Initialize the EvmOneBlockchainFixtureConsumer class.""" |
| 266 | + self.binary = binary if binary else self.default_binary |
| 267 | + super().__init__(trace=trace) |
| 268 | + |
| 269 | + def consume_fixture( |
| 270 | + self, |
| 271 | + fixture_format: FixtureFormat, |
| 272 | + fixture_path: Path, |
| 273 | + fixture_name: Optional[str] = None, |
| 274 | + debug_output_path: Optional[Path] = None, |
| 275 | + ): |
| 276 | + """ |
| 277 | + Execute the appropriate fixture consumer for the fixture at |
| 278 | + `fixture_path`. |
| 279 | + """ |
| 280 | + if fixture_format == BlockchainFixture: |
| 281 | + self.consume_test( |
| 282 | + fixture_path=fixture_path, |
| 283 | + fixture_name=fixture_name, |
| 284 | + debug_output_path=debug_output_path, |
| 285 | + ) |
| 286 | + else: |
| 287 | + pytest.skip(self._skip_message(fixture_format)) |
| 288 | + |
| 289 | + |
46 | 290 | class EvmoneExceptionMapper(ExceptionMapper):
|
47 | 291 | """
|
48 | 292 | Translate between EEST exceptions and error strings returned by Evmone.
|
|
0 commit comments