Skip to content

Commit b4e09b1

Browse files
authored
feat(plugins): Static test filler (#1336)
* feat(specs): Introduce static spec types * feat(plugins): Add Refiller plugin * fix: tox * feat(specs): Implement fork range descriptor parser * fix(refiller): Handle `ids` and `marks` * fix(refiller): Handle duplicate test ids * fix(specs): Add base static helpers * feat(plugins/forks): Add `valid_at` marker * fix(plugins/refiller): warn instead of error for parsing failures * fix(plugins/refiller): Check filler file name before loading * fix(fixtures): Fix TestInfo to remove `Filler` suffix * fix(refiller): Remove try-except to print parse errors * fix(fixtures/collector): Remove unused function * fix: Tox * refactor(refiller): Rename as static-filler * refactor(forks): Move ForkRangeDescriptor, get_fork_by_name * docs: Add changelog
1 parent 9462a41 commit b4e09b1

File tree

18 files changed

+747
-102
lines changed

18 files changed

+747
-102
lines changed

docs/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ consume cache --help
4040

4141
- 🐞 Fix `--fork/from/until` for transition forks when using `fill` [#1311](https://github.com/ethereum/execution-spec-tests/pull/1311).
4242
- 🐞 Fix the node id for state tests marked by transition forks ([#1313](https://github.com/ethereum/execution-spec-tests/pull/1313)).
43+
- ✨ Add `static_filler` plug-in which allows to fill static YAML and JSON tests (from [ethereum/tests](https://github.com/ethereum/tests)) by adding flag `--fill-static-tests` to `uv run fill` ([#1336](https://github.com/ethereum/execution-spec-tests/pull/1336)).
4344

4445
### 📋 Misc
4546

docs/filling_tests/index.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,3 +28,11 @@ The "fill-consume" method follows a differential testing approach: A reference i
2828
Some tests cases, particularly those without straightforward post-checks, such as certain gas calculations, may allow subtle inconsistencies to slip through during filling.
2929

3030
**Consequently, filling the tests does not ensure the client’s correctness. Clients must consume the tests to be considered correctly tested, even if that client was used to fill the tests.**
31+
32+
## Filling Static Tests from [ethereum/tests](https://github.com/ethereum/tests)
33+
34+
Filling static test fillers in YAML or JSON formats from [ethereum/tests](https://github.com/ethereum/tests/tree/develop/src) is possible by adding the `--fill-static-tests` to the `fill` command.
35+
36+
This functionality is only available for backwards compatibility and copying legacy tests from the [ethereum/tests](https://github.com/ethereum/tests) repository into this one.
37+
38+
Adding new static test fillers is otherwise not allowed.

docs/writing_tests/test_markers.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@ These markers are used to specify the forks for which a test is valid.
1616

1717
:::pytest_plugins.forks.forks.ValidUntil
1818

19+
### `@pytest.mark.valid_at("FORK_NAME_1", "FORK_NAME_2", ...)`
20+
21+
:::pytest_plugins.forks.forks.ValidAt
22+
1923
### `@pytest.mark.valid_at_transition_to("FORK_NAME")`
2024

2125
:::pytest_plugins.forks.forks.ValidAtTransitionTo

pytest.ini

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ addopts =
1111
-p pytest_plugins.filler.pre_alloc
1212
-p pytest_plugins.solc.solc
1313
-p pytest_plugins.filler.filler
14+
-p pytest_plugins.filler.static_filler
1415
-p pytest_plugins.shared.execute_fill
1516
-p pytest_plugins.forks.forks
1617
-p pytest_plugins.spec_version_checker.spec_version_checker

src/ethereum_test_fixtures/collector.py

Lines changed: 54 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
import sys
1010
from dataclasses import dataclass, field
1111
from pathlib import Path
12-
from typing import Dict, Literal, Optional, Tuple
12+
from typing import ClassVar, Dict, Literal, Optional, Tuple
1313

1414
from ethereum_test_base_types import to_json
1515

@@ -18,40 +18,26 @@
1818
from .file import Fixtures
1919

2020

21-
def strip_test_prefix(name: str) -> str:
22-
"""Remove test prefix from a test case name."""
23-
test_prefix = "test_"
24-
if name.startswith(test_prefix):
25-
return name[len(test_prefix) :]
26-
return name
27-
28-
29-
def get_module_relative_output_dir(test_module: Path, filler_path: Path) -> Path:
30-
"""
31-
Return a directory name for the provided test_module (relative to the
32-
base ./tests directory) that can be used for output (within the
33-
configured fixtures output path or the base_dump_dir directory).
34-
35-
Example:
36-
tests/shanghai/eip3855_push0/test_push0.py -> shanghai/eip3855_push0/test_push0
37-
38-
"""
39-
basename = test_module.with_suffix("").absolute()
40-
basename_relative = basename.relative_to(
41-
os.path.commonpath([filler_path.absolute(), basename])
42-
)
43-
module_path = basename_relative.parent / basename_relative.stem
44-
return module_path
45-
46-
4721
@dataclass(kw_only=True)
4822
class TestInfo:
4923
"""Contains test information from the current node."""
5024

51-
name: str # pytest: Item.name
52-
id: str # pytest: Item.nodeid
53-
original_name: str # pytest: Item.originalname
54-
path: Path # pytest: Item.path
25+
name: str # pytest: Item.name, e.g. test_paris_one[fork_Paris-state_test]
26+
id: str # pytest: Item.nodeid, e.g. tests/paris/test_module_paris.py::test_paris_one[...]
27+
original_name: str # pytest: Item.originalname, e.g. test_paris_one
28+
module_path: Path # pytest: Item.path, e.g. .../tests/paris/test_module_paris.py
29+
30+
test_prefix: ClassVar[str] = "test_" # Python test prefix
31+
filler_suffix: ClassVar[str] = "Filler" # Static test suffix
32+
33+
@classmethod
34+
def strip_test_name(cls, name: str) -> str:
35+
"""Remove test prefix from a python test case name."""
36+
if name.startswith(cls.test_prefix):
37+
return name[len(cls.test_prefix) :]
38+
if name.endswith(cls.filler_suffix):
39+
return name[: -len(cls.filler_suffix)]
40+
return name
5541

5642
def get_name_and_parameters(self) -> Tuple[str, str]:
5743
"""
@@ -64,10 +50,16 @@ def get_name_and_parameters(self) -> Tuple[str, str]:
6450
test_name, parameters = self.name.split("[")
6551
return test_name, re.sub(r"[\[\-]", "_", parameters).replace("]", "")
6652

67-
def get_single_test_name(self) -> str:
53+
def get_single_test_name(self, mode: Literal["module", "test"] = "module") -> str:
6854
"""Convert test name to a single test name."""
69-
test_name, test_parameters = self.get_name_and_parameters()
70-
return f"{test_name}__{test_parameters}"
55+
if mode == "module":
56+
# Use the module name as the test name
57+
return self.strip_test_name(self.original_name)
58+
elif mode == "test":
59+
# Mix the module name and the test name/arguments
60+
test_name, test_parameters = self.get_name_and_parameters()
61+
test_name = self.strip_test_name(test_name)
62+
return f"{test_name}__{test_parameters}"
7163

7264
def get_dump_dir_path(
7365
self,
@@ -78,7 +70,7 @@ def get_dump_dir_path(
7870
"""Path to dump the debug output as defined by the level to dump at."""
7971
if not base_dump_dir:
8072
return None
81-
test_module_relative_dir = get_module_relative_output_dir(self.path, filler_path)
73+
test_module_relative_dir = self.get_module_relative_output_dir(filler_path)
8274
if level == "test_module":
8375
return Path(base_dump_dir) / Path(str(test_module_relative_dir).replace(os.sep, "__"))
8476
test_name, test_parameter_string = self.get_name_and_parameters()
@@ -89,6 +81,27 @@ def get_dump_dir_path(
8981
return Path(base_dump_dir) / flat_path / test_parameter_string
9082
raise Exception("Unexpected level.")
9183

84+
def get_id(self) -> str:
85+
"""Return the test id."""
86+
return self.id
87+
88+
def get_module_relative_output_dir(self, filler_path: Path) -> Path:
89+
"""
90+
Return a directory name for the provided test_module (relative to the
91+
base ./tests directory) that can be used for output (within the
92+
configured fixtures output path or the base_dump_dir directory).
93+
94+
Example:
95+
tests/shanghai/eip3855_push0/test_push0.py -> shanghai/eip3855_push0/test_push0
96+
97+
"""
98+
basename = self.module_path.with_suffix("").absolute()
99+
basename_relative = basename.relative_to(
100+
os.path.commonpath([filler_path.absolute(), basename])
101+
)
102+
module_path = basename_relative.parent / self.strip_test_name(basename_relative.stem)
103+
return module_path
104+
92105

93106
@dataclass(kw_only=True)
94107
class FixtureCollector:
@@ -108,19 +121,14 @@ def get_fixture_basename(self, info: TestInfo) -> Path:
108121
"""Return basename of the fixture file for a given test case."""
109122
if self.flat_output:
110123
if self.single_fixture_per_file:
111-
return Path(strip_test_prefix(info.get_single_test_name()))
112-
return Path(strip_test_prefix(info.original_name))
124+
return Path(info.get_single_test_name(mode="test"))
125+
return Path(info.get_single_test_name(mode="module"))
113126
else:
114-
relative_fixture_output_dir = Path(info.path).parent / strip_test_prefix(
115-
Path(info.path).stem
116-
)
117-
module_relative_output_dir = get_module_relative_output_dir(
118-
relative_fixture_output_dir, self.filler_path
119-
)
127+
module_relative_output_dir = info.get_module_relative_output_dir(self.filler_path)
120128

121129
if self.single_fixture_per_file:
122-
return module_relative_output_dir / strip_test_prefix(info.get_single_test_name())
123-
return module_relative_output_dir / strip_test_prefix(info.original_name)
130+
return module_relative_output_dir / info.get_single_test_name(mode="test")
131+
return module_relative_output_dir / info.get_single_test_name(mode="module")
124132

125133
def add_fixture(self, info: TestInfo, fixture: BaseFixture) -> Path:
126134
"""Add fixture to the list of fixtures of a given test case."""
@@ -135,7 +143,7 @@ def add_fixture(self, info: TestInfo, fixture: BaseFixture) -> Path:
135143
self.all_fixtures[fixture_path] = Fixtures(root={})
136144
self.json_path_to_test_item[fixture_path] = info
137145

138-
self.all_fixtures[fixture_path][info.id] = fixture
146+
self.all_fixtures[fixture_path][info.get_id()] = fixture
139147

140148
return fixture_path
141149

src/ethereum_test_forks/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,12 +27,14 @@
2727
)
2828
from .gas_costs import GasCosts
2929
from .helpers import (
30+
ForkRangeDescriptor,
3031
InvalidForkError,
3132
forks_from,
3233
forks_from_until,
3334
get_closest_fork_with_solc_support,
3435
get_deployed_forks,
3536
get_development_forks,
37+
get_fork_by_name,
3638
get_forks,
3739
get_forks_with_no_descendants,
3840
get_forks_with_no_parents,
@@ -57,6 +59,7 @@
5759
"Byzantium",
5860
"Constantinople",
5961
"ConstantinopleFix",
62+
"ForkRangeDescriptor",
6063
"Frontier",
6164
"GrayGlacier",
6265
"Homestead",
@@ -80,6 +83,7 @@
8083
"get_development_forks",
8184
"get_transition_fork_predecessor",
8285
"get_transition_fork_successor",
86+
"get_fork_by_name",
8387
"get_forks_with_no_descendants",
8488
"get_forks_with_no_parents",
8589
"get_forks_with_solc_support",

src/ethereum_test_forks/helpers.py

Lines changed: 70 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
"""Helper methods to resolve forks during test filling."""
22

3-
from typing import List, Optional, Set
3+
import re
4+
from typing import Any, List, Optional, Set
45

6+
from pydantic import BaseModel, ConfigDict, ValidatorFunctionWrapHandler, model_validator
57
from semver import Version
68

79
from .base_fork import BaseFork, Fork
@@ -17,18 +19,20 @@ def __init__(self, message):
1719
super().__init__(message)
1820

1921

22+
all_forks: List[Fork] = []
23+
for fork_name in forks.__dict__:
24+
fork = forks.__dict__[fork_name]
25+
if not isinstance(fork, type):
26+
continue
27+
if issubclass(fork, BaseFork) and fork is not BaseFork:
28+
all_forks.append(fork)
29+
30+
2031
def get_forks() -> List[Fork]:
2132
"""
2233
Return list of all the fork classes implemented by
2334
`ethereum_test_forks` ordered chronologically by deployment.
2435
"""
25-
all_forks: List[Fork] = []
26-
for fork_name in forks.__dict__:
27-
fork = forks.__dict__[fork_name]
28-
if not isinstance(fork, type):
29-
continue
30-
if issubclass(fork, BaseFork) and fork is not BaseFork:
31-
all_forks.append(fork)
3236
return all_forks
3337

3438

@@ -250,3 +254,61 @@ def get_relative_fork_markers(fork_identifier: Fork | str, strict_mode: bool = T
250254
return [fork_class.name(), fork_class.transitions_to().name()]
251255
else:
252256
return [fork_class.name()]
257+
258+
259+
def get_fork_by_name(fork_name: str) -> Fork | None:
260+
"""Get a fork by name."""
261+
for fork in get_forks():
262+
if fork.name() == fork_name:
263+
return fork
264+
return None
265+
266+
267+
class ForkRangeDescriptor(BaseModel):
268+
"""Fork descriptor parsed from string normally contained in ethereum/tests fillers."""
269+
270+
greater_equal: Fork | None = None
271+
less_than: Fork | None = None
272+
model_config = ConfigDict(frozen=True)
273+
274+
def fork_in_range(self, fork: Fork) -> bool:
275+
"""Return whether the given fork is within range."""
276+
if self.greater_equal is not None and fork < self.greater_equal:
277+
return False
278+
if self.less_than is not None and fork >= self.less_than:
279+
return False
280+
return True
281+
282+
@model_validator(mode="wrap")
283+
@classmethod
284+
def validate_fork_range_descriptor(cls, v: Any, handler: ValidatorFunctionWrapHandler):
285+
"""
286+
Validate the fork range descriptor from a string.
287+
288+
Examples:
289+
- ">=Osaka" validates to {greater_equal=Osaka, less_than=None}
290+
- ">=Prague<Osaka" validates to {greater_equal=Prague, less_than=Osaka}
291+
292+
"""
293+
if isinstance(v, str):
294+
# Decompose the string into its parts
295+
descriptor_string = re.sub(r"\s+", "", v.strip())
296+
v = {}
297+
if m := re.search(r">=(\w+)", descriptor_string):
298+
fork: Fork | None = get_fork_by_name(m.group(1))
299+
if fork is None:
300+
raise Exception(f"Unable to parse fork name: {m.group(1)}")
301+
v["greater_equal"] = fork
302+
descriptor_string = re.sub(r">=(\w+)", "", descriptor_string)
303+
if m := re.search(r"<(\w+)", descriptor_string):
304+
fork = get_fork_by_name(m.group(1))
305+
if fork is None:
306+
raise Exception(f"Unable to parse fork name: {m.group(1)}")
307+
v["less_than"] = fork
308+
descriptor_string = re.sub(r"<(\w+)", "", descriptor_string)
309+
if descriptor_string:
310+
raise Exception(
311+
"Unable to completely parse fork range descriptor. "
312+
+ f'Remaining string: "{descriptor_string}"'
313+
)
314+
return handler(v)
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
"""Test fork range descriptor parsing from string."""
2+
3+
import pytest
4+
5+
from ..forks.forks import Osaka, Prague
6+
from ..helpers import ForkRangeDescriptor
7+
8+
9+
@pytest.mark.parametrize(
10+
"fork_range_descriptor_string,expected_fork_range_descriptor",
11+
[
12+
(
13+
">=Osaka",
14+
ForkRangeDescriptor(
15+
greater_equal=Osaka,
16+
less_than=None,
17+
),
18+
),
19+
(
20+
">= Prague < Osaka",
21+
ForkRangeDescriptor(
22+
greater_equal=Prague,
23+
less_than=Osaka,
24+
),
25+
),
26+
],
27+
)
28+
def test_parsing_fork_range_descriptor_from_string(
29+
fork_range_descriptor_string: str,
30+
expected_fork_range_descriptor: ForkRangeDescriptor,
31+
):
32+
"""Test multiple strings used as fork range descriptors in ethereum/tests."""
33+
assert (
34+
ForkRangeDescriptor.model_validate(fork_range_descriptor_string)
35+
== expected_fork_range_descriptor
36+
)

src/ethereum_test_specs/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from typing import List, Type
44

55
from .base import BaseTest, TestSpec
6+
from .base_static import BaseStaticTest
67
from .blockchain import (
78
BlockchainTest,
89
BlockchainTestFiller,
@@ -30,6 +31,7 @@
3031

3132
__all__ = (
3233
"SPEC_TYPES",
34+
"BaseStaticTest",
3335
"BaseTest",
3436
"BlockchainTest",
3537
"BlockchainTestEngineFiller",

0 commit comments

Comments
 (0)