Skip to content

Commit 6790446

Browse files
committed
refactor(consume): rename conftest to single_test_client plugin
Plugins called 'conftest' are registered automatically if in a sub-path to a test. This rename requires an explicit registration, which is specified by defining the `pytest_plugins` variable in 'engine/conftest.py', respectively, 'rlp/conftest.py'.
1 parent f5313dd commit 6790446

File tree

1 file changed

+354
-2
lines changed

1 file changed

+354
-2
lines changed

src/pytest_plugins/consume/simulators/single_test_client.py

Lines changed: 354 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,24 +3,240 @@
33
import io
44
import json
55
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
711

812
import pytest
13+
import rich
914
from hive.client import Client, ClientType
1015
from hive.testing import HiveTest
1116

1217
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+
)
1423
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
1528
from pytest_plugins.consume.simulators.helpers.ruleset import (
1629
ruleset, # TODO: generate dynamically
1730
)
31+
from pytest_plugins.pytest_hive.hive_info import ClientFile, HiveInfo
1832

33+
from .helpers.exceptions import EXCEPTION_MAPPERS
1934
from .helpers.timing import TimingData
2035

2136
logger = logging.getLogger(__name__)
2237

2338

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}\nHive 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+
24240
@pytest.fixture(scope="function")
25241
def client_genesis(fixture: BlockchainFixtureCommon) -> dict:
26242
"""Convert the fixture genesis block header and pre-state to a client genesis state."""
@@ -31,6 +247,19 @@ def client_genesis(fixture: BlockchainFixtureCommon) -> dict:
31247
return genesis
32248

33249

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+
34263
@pytest.fixture(scope="function")
35264
def environment(
36265
fixture: BlockchainFixtureCommon,
@@ -61,6 +290,67 @@ def genesis_header(fixture: BlockchainFixtureCommon) -> FixtureHeader:
61290
return fixture.genesis # type: ignore
62291

63292

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+
64354
@pytest.fixture(scope="function")
65355
def client(
66356
hive_test: HiveTest,
@@ -86,3 +376,65 @@ def client(
86376
with total_timing_data.time("Stop client"):
87377
client.stop()
88378
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

Comments
 (0)