3
3
import io
4
4
import json
5
5
import logging
6
- from typing import Generator , Literal , cast
6
+ import textwrap
7
+ import urllib
8
+ import warnings
9
+ from pathlib import Path
10
+ from typing import Dict , Generator , List , Literal , cast
7
11
8
12
import pytest
13
+ import rich
9
14
from hive .client import Client , ClientType
10
15
from hive .testing import HiveTest
11
16
12
17
from ethereum_test_base_types import Number , to_json
13
- from ethereum_test_fixtures import BlockchainFixtureCommon
18
+ from ethereum_test_exceptions import ExceptionMapper
19
+ from ethereum_test_fixtures import (
20
+ BaseFixture ,
21
+ BlockchainFixtureCommon ,
22
+ )
14
23
from ethereum_test_fixtures .blockchain import FixtureHeader
24
+ from ethereum_test_fixtures .consume import TestCaseIndexFile , TestCaseStream
25
+ from ethereum_test_fixtures .file import Fixtures
26
+ from ethereum_test_rpc import EthRPC
27
+ from pytest_plugins .consume .consume import FixturesSource
15
28
from pytest_plugins .consume .simulators .helpers .ruleset import (
16
29
ruleset , # TODO: generate dynamically
17
30
)
31
+ from pytest_plugins .pytest_hive .hive_info import ClientFile , HiveInfo
18
32
33
+ from .helpers .exceptions import EXCEPTION_MAPPERS
19
34
from .helpers .timing import TimingData
20
35
21
36
logger = logging .getLogger (__name__ )
22
37
23
38
39
+ def pytest_addoption (parser ):
40
+ """Hive simulator specific consume command line options."""
41
+ consume_group = parser .getgroup (
42
+ "consume" , "Arguments related to consuming fixtures via a client"
43
+ )
44
+ consume_group .addoption (
45
+ "--timing-data" ,
46
+ action = "store_true" ,
47
+ dest = "timing_data" ,
48
+ default = False ,
49
+ help = "Log the timing data for each test case execution." ,
50
+ )
51
+ consume_group .addoption (
52
+ "--disable-strict-exception-matching" ,
53
+ action = "store" ,
54
+ dest = "disable_strict_exception_matching" ,
55
+ default = "" ,
56
+ help = (
57
+ "Comma-separated list of client names and/or forks which should NOT use strict "
58
+ "exception matching."
59
+ ),
60
+ )
61
+
62
+
63
+ @pytest .fixture (scope = "function" )
64
+ def eth_rpc (client : Client ) -> EthRPC :
65
+ """Initialize ethereum RPC client for the execution client under test."""
66
+ return EthRPC (f"http://{ client .ip } :8545" )
67
+
68
+
69
+ @pytest .fixture (scope = "function" )
70
+ def hive_clients_yaml_target_filename () -> str :
71
+ """Return the name of the target clients YAML file."""
72
+ return "clients_eest.yaml"
73
+
74
+
75
+ @pytest .fixture (scope = "function" )
76
+ def hive_clients_yaml_generator_command (
77
+ client_type : ClientType ,
78
+ client_file : ClientFile ,
79
+ hive_clients_yaml_target_filename : str ,
80
+ hive_info : HiveInfo ,
81
+ ) -> str :
82
+ """Generate a shell command that creates a clients YAML file for the current client."""
83
+ try :
84
+ if not client_file :
85
+ raise ValueError ("No client information available - try updating hive" )
86
+ client_config = [c for c in client_file .root if c .client in client_type .name ]
87
+ if not client_config :
88
+ raise ValueError (f"Client '{ client_type .name } ' not found in client file" )
89
+ try :
90
+ yaml_content = ClientFile (root = [client_config [0 ]]).yaml ().replace (" " , " " )
91
+ return f'echo "\\ \n { yaml_content } " > { hive_clients_yaml_target_filename } '
92
+ except Exception as e :
93
+ raise ValueError (f"Failed to generate YAML: { str (e )} " ) from e
94
+ except ValueError as e :
95
+ error_message = str (e )
96
+ warnings .warn (
97
+ f"{ error_message } . The Hive clients YAML generator command will not be available." ,
98
+ stacklevel = 2 ,
99
+ )
100
+
101
+ issue_title = f"Client { client_type .name } configuration issue"
102
+ issue_body = f"Error: { error_message } \n Hive version: { hive_info .commit } \n "
103
+ issue_url = f"https://github.com/ethereum/execution-spec-tests/issues/new?title={ urllib .parse .quote (issue_title )} &body={ urllib .parse .quote (issue_body )} "
104
+
105
+ return (
106
+ f"Error: { error_message } \n "
107
+ f'Please <a href="{ issue_url } ">create an issue</a> to report this problem.'
108
+ )
109
+
110
+
111
+ @pytest .fixture (scope = "function" )
112
+ def filtered_hive_options (hive_info : HiveInfo ) -> List [str ]:
113
+ """Filter Hive command options to remove unwanted options."""
114
+ logger .info ("Hive info: %s" , hive_info .command )
115
+
116
+ unwanted_options = [
117
+ "--client" , # gets overwritten: we specify a single client; the one from the test case
118
+ "--client-file" , # gets overwritten: we'll write our own client file
119
+ "--results-root" , # use default value instead (or you have to pass it to ./hiveview)
120
+ "--sim.limit" , # gets overwritten: we only run the current test case id
121
+ "--sim.parallelism" , # skip; we'll only be running a single test
122
+ ]
123
+
124
+ command_parts = []
125
+ skip_next = False
126
+ for part in hive_info .command :
127
+ if skip_next :
128
+ skip_next = False
129
+ continue
130
+
131
+ if part in unwanted_options :
132
+ skip_next = True
133
+ continue
134
+
135
+ if any (part .startswith (f"{ option } =" ) for option in unwanted_options ):
136
+ continue
137
+
138
+ command_parts .append (part )
139
+
140
+ return command_parts
141
+
142
+
143
+ @pytest .fixture (scope = "function" )
144
+ def hive_client_config_file_parameter (hive_clients_yaml_target_filename : str ) -> str :
145
+ """Return the hive client config file parameter."""
146
+ return f"--client-file { hive_clients_yaml_target_filename } "
147
+
148
+
149
+ @pytest .fixture (scope = "function" )
150
+ def hive_consume_command (
151
+ test_case : TestCaseIndexFile | TestCaseStream ,
152
+ hive_client_config_file_parameter : str ,
153
+ filtered_hive_options : List [str ],
154
+ client_type : ClientType ,
155
+ ) -> str :
156
+ """Command to run the test within hive."""
157
+ command_parts = filtered_hive_options .copy ()
158
+ command_parts .append (f"{ hive_client_config_file_parameter } " )
159
+ command_parts .append (f"--client={ client_type .name } " )
160
+ command_parts .append (f'--sim.limit="id:{ test_case .id } "' )
161
+
162
+ return " " .join (command_parts )
163
+
164
+
165
+ @pytest .fixture (scope = "function" )
166
+ def hive_dev_command (
167
+ client_type : ClientType ,
168
+ hive_client_config_file_parameter : str ,
169
+ ) -> str :
170
+ """Return the command used to instantiate hive alongside the `consume` command."""
171
+ return f"./hive --dev { hive_client_config_file_parameter } --client { client_type .name } "
172
+
173
+
174
+ @pytest .fixture (scope = "function" )
175
+ def eest_consume_command (
176
+ test_suite_name : str ,
177
+ test_case : TestCaseIndexFile | TestCaseStream ,
178
+ fixture_source_flags : List [str ],
179
+ ) -> str :
180
+ """Commands to run the test within EEST using a hive dev back-end."""
181
+ flags = " " .join (fixture_source_flags )
182
+ return (
183
+ f"uv run consume { test_suite_name .split ('-' )[- 1 ]} "
184
+ f'{ flags } --sim.limit="id:{ test_case .id } " -v -s'
185
+ )
186
+
187
+
188
+ @pytest .fixture (scope = "function" )
189
+ def test_case_description (
190
+ fixture : BaseFixture ,
191
+ test_case : TestCaseIndexFile | TestCaseStream ,
192
+ hive_clients_yaml_generator_command : str ,
193
+ hive_consume_command : str ,
194
+ hive_dev_command : str ,
195
+ eest_consume_command : str ,
196
+ ) -> str :
197
+ """Create the description of the current blockchain fixture test case."""
198
+ test_url = fixture .info .get ("url" , "" )
199
+
200
+ if "description" not in fixture .info or fixture .info ["description" ] is None :
201
+ test_docstring = "No documentation available."
202
+ else :
203
+ # this prefix was included in the fixture description field for fixtures <= v4.3.0
204
+ test_docstring = fixture .info ["description" ].replace ("Test function documentation:\n " , "" ) # type: ignore
205
+
206
+ description = textwrap .dedent (f"""
207
+ <b>Test Details</b>
208
+ <code>{ test_case .id } </code>
209
+ { f'<a href="{ test_url } ">[source]</a>' if test_url else "" }
210
+
211
+ { test_docstring }
212
+
213
+ <b>Run This Test Locally:</b>
214
+ To run this test in <a href="https://github.com/ethereum/hive">hive</a></i>:
215
+ <code>{ hive_clients_yaml_generator_command }
216
+ { hive_consume_command } </code>
217
+
218
+ <b>Advanced: Run the test against a hive developer backend using EEST's <code>consume</code> command</b>
219
+ Create the client YAML file, as above, then:
220
+ 1. Start hive in dev mode: <code>{ hive_dev_command } </code>
221
+ 2. In the EEST repository root: <code>{ eest_consume_command } </code>
222
+ """ ) # noqa: E501
223
+
224
+ description = description .strip ()
225
+ description = description .replace ("\n " , "<br/>" )
226
+ return description
227
+
228
+
229
+ @pytest .fixture (scope = "function" , autouse = True )
230
+ def total_timing_data (request ) -> Generator [TimingData , None , None ]:
231
+ """Record timing data for various stages of executing test case."""
232
+ with TimingData ("Total (seconds)" ) as total_timing_data :
233
+ yield total_timing_data
234
+ if request .config .getoption ("timing_data" ):
235
+ rich .print (f"\n { total_timing_data .formatted ()} " )
236
+ if hasattr (request .node , "rep_call" ): # make available for test reports
237
+ request .node .rep_call .timings = total_timing_data
238
+
239
+
24
240
@pytest .fixture (scope = "function" )
25
241
def client_genesis (fixture : BlockchainFixtureCommon ) -> dict :
26
242
"""Convert the fixture genesis block header and pre-state to a client genesis state."""
@@ -31,6 +247,19 @@ def client_genesis(fixture: BlockchainFixtureCommon) -> dict:
31
247
return genesis
32
248
33
249
250
+ @pytest .fixture (scope = "function" )
251
+ @pytest .fixture (scope = "function" )
252
+ def check_live_port (test_suite_name : str ) -> Literal [8545 , 8551 ]:
253
+ """Port used by hive to check for liveness of the client."""
254
+ if test_suite_name == "eest/consume-rlp" :
255
+ return 8545
256
+ elif test_suite_name == "eest/consume-engine" :
257
+ return 8551
258
+ raise ValueError (
259
+ f"Unexpected test suite name '{ test_suite_name } ' while setting HIVE_CHECK_LIVE_PORT."
260
+ )
261
+
262
+
34
263
@pytest .fixture (scope = "function" )
35
264
def environment (
36
265
fixture : BlockchainFixtureCommon ,
@@ -61,6 +290,67 @@ def genesis_header(fixture: BlockchainFixtureCommon) -> FixtureHeader:
61
290
return fixture .genesis # type: ignore
62
291
63
292
293
+ @pytest .fixture (scope = "session" )
294
+ def client_exception_mapper_cache ():
295
+ """Cache for exception mappers by client type."""
296
+ return {}
297
+
298
+
299
+ @pytest .fixture (scope = "function" )
300
+ def client_exception_mapper (
301
+ client_type : ClientType , client_exception_mapper_cache
302
+ ) -> ExceptionMapper | None :
303
+ """Return the exception mapper for the client type, with caching."""
304
+ if client_type .name not in client_exception_mapper_cache :
305
+ for client in EXCEPTION_MAPPERS :
306
+ if client in client_type .name :
307
+ client_exception_mapper_cache [client_type .name ] = EXCEPTION_MAPPERS [client ]
308
+ break
309
+ else :
310
+ client_exception_mapper_cache [client_type .name ] = None
311
+
312
+ return client_exception_mapper_cache [client_type .name ]
313
+
314
+
315
+ @pytest .fixture (scope = "session" )
316
+ def disable_strict_exception_matching (request : pytest .FixtureRequest ) -> List [str ]:
317
+ """Return the list of clients or forks that should NOT use strict exception matching."""
318
+ config_string = request .config .getoption ("disable_strict_exception_matching" )
319
+ return config_string .split ("," ) if config_string else []
320
+
321
+
322
+ @pytest .fixture (scope = "function" )
323
+ def client_strict_exception_matching (
324
+ client_type : ClientType ,
325
+ disable_strict_exception_matching : List [str ],
326
+ ) -> bool :
327
+ """Return True if the client type should use strict exception matching."""
328
+ return not any (
329
+ client .lower () in client_type .name .lower () for client in disable_strict_exception_matching
330
+ )
331
+
332
+
333
+ @pytest .fixture (scope = "function" )
334
+ def fork_strict_exception_matching (
335
+ fixture : BlockchainFixtureCommon ,
336
+ disable_strict_exception_matching : List [str ],
337
+ ) -> bool :
338
+ """Return True if the fork should use strict exception matching."""
339
+ # NOTE: `in` makes it easier for transition forks ("Prague" in "CancunToPragueAtTime15k")
340
+ return not any (
341
+ s .lower () in str (fixture .fork ).lower () for s in disable_strict_exception_matching
342
+ )
343
+
344
+
345
+ @pytest .fixture (scope = "function" )
346
+ def strict_exception_matching (
347
+ client_strict_exception_matching : bool ,
348
+ fork_strict_exception_matching : bool ,
349
+ ) -> bool :
350
+ """Return True if the test should use strict exception matching."""
351
+ return client_strict_exception_matching and fork_strict_exception_matching
352
+
353
+
64
354
@pytest .fixture (scope = "function" )
65
355
def client (
66
356
hive_test : HiveTest ,
@@ -86,3 +376,65 @@ def client(
86
376
with total_timing_data .time ("Stop client" ):
87
377
client .stop ()
88
378
logger .info (f"Client ({ client_type .name } ) stopped!" )
379
+
380
+
381
+ @pytest .fixture (scope = "function" , autouse = True )
382
+ def timing_data (
383
+ total_timing_data : TimingData , client : Client
384
+ ) -> Generator [TimingData , None , None ]:
385
+ """Record timing data for the main execution of the test case."""
386
+ with total_timing_data .time ("Test case execution" ) as timing_data :
387
+ yield timing_data
388
+
389
+
390
+ class FixturesDict (Dict [Path , Fixtures ]):
391
+ """
392
+ A dictionary caches loaded fixture files to avoid reloading the same file
393
+ multiple times.
394
+ """
395
+
396
+ def __init__ (self ) -> None :
397
+ """Initialize the dictionary that caches loaded fixture files."""
398
+ self ._fixtures : Dict [Path , Fixtures ] = {}
399
+
400
+ def __getitem__ (self , key : Path ) -> Fixtures :
401
+ """Return the fixtures from the index file, if not found, load from disk."""
402
+ assert key .is_file (), f"Expected a file path, got '{ key } '"
403
+ if key not in self ._fixtures :
404
+ self ._fixtures [key ] = Fixtures .model_validate_json (key .read_text ())
405
+ return self ._fixtures [key ]
406
+
407
+
408
+ @pytest .fixture (scope = "session" )
409
+ def fixture_file_loader () -> Dict [Path , Fixtures ]:
410
+ """Return a singleton dictionary that caches loaded fixture files used in all tests."""
411
+ return FixturesDict ()
412
+
413
+
414
+ @pytest .fixture (scope = "function" )
415
+ def fixture (
416
+ fixtures_source : FixturesSource ,
417
+ fixture_file_loader : Dict [Path , Fixtures ],
418
+ test_case : TestCaseIndexFile | TestCaseStream ,
419
+ ) -> BaseFixture :
420
+ """
421
+ Load the fixture from a file or from stream in any of the supported
422
+ fixture formats.
423
+
424
+ The fixture is either already available within the test case (if consume
425
+ is taking input on stdin) or loaded from the fixture json file if taking
426
+ input from disk (fixture directory with index file).
427
+ """
428
+ fixture : BaseFixture
429
+ if fixtures_source .is_stdin :
430
+ assert isinstance (test_case , TestCaseStream ), "Expected a stream test case"
431
+ fixture = test_case .fixture
432
+ else :
433
+ assert isinstance (test_case , TestCaseIndexFile ), "Expected an index file test case"
434
+ fixtures_file_path = fixtures_source .path / test_case .json_path
435
+ fixtures : Fixtures = fixture_file_loader [fixtures_file_path ]
436
+ fixture = fixtures [test_case .id ]
437
+ assert isinstance (fixture , test_case .format ), (
438
+ f"Expected a { test_case .format .format_name } test fixture"
439
+ )
440
+ return fixture
0 commit comments