Skip to content

Commit 1a43361

Browse files
winsvegamarioevz
authored andcommitted
feat(plugins/filler/static): do not compile test fillers source code while loading the models (ethereum#1439)
* do not compile test fillers source code while loading the models * support docker lllc image * export filled static state tests into test suite folder. Each legacy test filler has only 1 test per file if it's a !state test! So no need to create directory Add11/add11.json it can be plain add11.json * Apply suggestions from code review * fix(specs): Prefer local `lllc` binary * feat(filler): Allow arbitrary markers for static tests * feat(specs): Read pytest markers from static yml/json files * fix(filler): Propagate parametrize marks to generated test * fix(specs/static_state): Parametrization to add `exception_test` marker * fix(plugins/filler): Use sorted intersection fork set * fix: tox * Update src/ethereum_test_specs/static_state/common/common.py --------- Co-authored-by: Mario Vega <[email protected]>
1 parent c6403a9 commit 1a43361

File tree

10 files changed

+267
-130
lines changed

10 files changed

+267
-130
lines changed

src/ethereum_test_fixtures/collector.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@ class FixtureCollector:
109109

110110
output_dir: Path
111111
flat_output: bool
112+
fill_static_tests: bool
112113
single_fixture_per_file: bool
113114
filler_path: Path
114115
base_dump_dir: Optional[Path] = None
@@ -125,6 +126,12 @@ def get_fixture_basename(self, info: TestInfo) -> Path:
125126
return Path(info.get_single_test_name(mode="module"))
126127
else:
127128
module_relative_output_dir = info.get_module_relative_output_dir(self.filler_path)
129+
130+
# Each legacy test filler has only 1 test per file if it's a !state test!
131+
# So no need to create directory Add11/add11.json it can be plain add11.json
132+
if self.fill_static_tests:
133+
return module_relative_output_dir.parent / info.original_name
134+
128135
if self.single_fixture_per_file:
129136
return module_relative_output_dir / info.get_single_test_name(mode="test")
130137
return module_relative_output_dir / info.get_single_test_name(mode="module")

src/ethereum_test_specs/static_state/account.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,4 @@ class Config:
1919
"""Model Config."""
2020

2121
extra = "forbid"
22+
arbitrary_types_allowed = True # For CodeInFiller

src/ethereum_test_specs/static_state/common/common.py

Lines changed: 131 additions & 107 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,15 @@
33
import re
44
import subprocess
55
import tempfile
6-
from typing import Tuple
6+
from functools import cached_property
7+
from typing import Any
78

89
from eth_abi import encode
910
from eth_utils import function_signature_to_4byte_selector
10-
from pydantic import BaseModel, Field
1111
from pydantic.functional_validators import BeforeValidator
1212
from typing_extensions import Annotated
1313

14-
from ethereum_test_base_types import Address, Bytes, Hash, HexNumber
14+
from ethereum_test_base_types import Address, Hash, HexNumber
1515

1616
from .compile_yul import compile_yul
1717

@@ -30,12 +30,6 @@ def parse_hex_number(i: str | int) -> int:
3030
return int(i, 10)
3131

3232

33-
class CodeOptions(BaseModel):
34-
"""Define options of the code."""
35-
36-
label: str = Field("")
37-
38-
3933
def parse_args_from_string_into_array(stream: str, pos: int, delim: str = " "):
4034
"""Parse YUL options into array."""
4135
args = []
@@ -53,116 +47,146 @@ def parse_args_from_string_into_array(stream: str, pos: int, delim: str = " "):
5347
return args, pos
5448

5549

56-
def parse_code(code: str) -> Tuple[bytes, CodeOptions]:
57-
"""Check if the given string is a valid code."""
58-
# print("parse `" + str(code) + "`")
59-
if isinstance(code, int):
60-
# Users pass code as int (very bad)
61-
hex_str = format(code, "02x")
62-
return (bytes.fromhex(hex_str), CodeOptions())
63-
if not isinstance(code, str):
64-
raise ValueError(f"parse_code(code: str) code is not string: {code}")
65-
if len(code) == 0:
66-
return (bytes.fromhex(""), CodeOptions())
67-
68-
compiled_code = ""
69-
code_options: CodeOptions = CodeOptions()
70-
71-
raw_marker = ":raw 0x"
72-
raw_index = code.find(raw_marker)
73-
abi_marker = ":abi"
74-
abi_index = code.find(abi_marker)
50+
class CodeInFillerSource:
51+
"""Not compiled code source in test filler."""
52+
53+
code_label: str | None
54+
code_raw: Any
55+
56+
def __init__(self, code: Any, label: str | None = None):
57+
"""Instantiate."""
58+
self.code_label = label
59+
self.code_raw = code
60+
61+
@cached_property
62+
def compiled(self) -> bytes:
63+
"""Compile the code from source to bytes."""
64+
if isinstance(self.code_raw, int):
65+
# Users pass code as int (very bad)
66+
hex_str = format(self.code_raw, "02x")
67+
return bytes.fromhex(hex_str)
68+
69+
if not isinstance(self.code_raw, str):
70+
raise ValueError(f"parse_code(code: str) code is not string: {self.code_raw}")
71+
if len(self.code_raw) == 0:
72+
return b""
73+
74+
compiled_code = ""
75+
76+
raw_marker = ":raw 0x"
77+
raw_index = self.code_raw.find(raw_marker)
78+
abi_marker = ":abi"
79+
abi_index = self.code_raw.find(abi_marker)
80+
yul_marker = ":yul"
81+
yul_index = self.code_raw.find(yul_marker)
82+
83+
# Parse :raw
84+
if raw_index != -1:
85+
compiled_code = self.code_raw[raw_index + len(raw_marker) :]
86+
87+
# Parse :yul
88+
elif yul_index != -1:
89+
option_start = yul_index + len(yul_marker)
90+
options: list[str] = []
91+
native_yul_options: str = ""
92+
93+
if self.code_raw[option_start:].lstrip().startswith("{"):
94+
# No yul options, proceed to code parsing
95+
source_start = option_start
96+
else:
97+
opt, source_start = parse_args_from_string_into_array(
98+
self.code_raw, option_start + 1
99+
)
100+
for arg in opt:
101+
if arg == "object" or arg == '"C"':
102+
native_yul_options += arg + " "
103+
else:
104+
options.append(arg)
105+
106+
with tempfile.NamedTemporaryFile(mode="w+", delete=False) as tmp:
107+
tmp.write(native_yul_options + self.code_raw[source_start:])
108+
tmp_path = tmp.name
109+
compiled_code = compile_yul(
110+
source_file=tmp_path,
111+
evm_version=options[0] if len(options) >= 1 else None,
112+
optimize=options[1] if len(options) >= 2 else None,
113+
)[2:]
114+
115+
# Parse :abi
116+
elif abi_index != -1:
117+
abi_encoding = self.code_raw[abi_index + len(abi_marker) + 1 :]
118+
tokens = abi_encoding.strip().split()
119+
abi = tokens[0]
120+
function_signature = function_signature_to_4byte_selector(abi)
121+
parameter_str = re.sub(r"^\w+", "", abi).strip()
122+
123+
parameter_types = parameter_str.strip("()").split(",")
124+
if len(tokens) > 1:
125+
function_parameters = encode(
126+
[parameter_str],
127+
[
128+
[
129+
int(t.lower(), 0) & ((1 << 256) - 1) # treat big ints as 256bits
130+
if parameter_types[t_index] == "uint"
131+
else int(t.lower(), 0) > 0 # treat positive values as True
132+
if parameter_types[t_index] == "bool"
133+
else False and ValueError("unhandled parameter_types")
134+
for t_index, t in enumerate(tokens[1:])
135+
]
136+
],
137+
)
138+
return function_signature + function_parameters
139+
return function_signature
140+
141+
# Parse plain code 0x
142+
elif self.code_raw.lstrip().startswith("0x"):
143+
compiled_code = self.code_raw[2:].lower()
144+
145+
# Parse lllc code
146+
elif self.code_raw.lstrip().startswith("{") or self.code_raw.lstrip().startswith("(asm"):
147+
with tempfile.NamedTemporaryFile(mode="w+", delete=False) as tmp:
148+
tmp.write(self.code_raw)
149+
tmp_path = tmp.name
150+
151+
# - using lllc
152+
result = subprocess.run(["lllc", tmp_path], capture_output=True, text=True)
153+
154+
# - using docker:
155+
# If the running machine does not have lllc installed, we can use docker to run lllc,
156+
# but we need to start a container first, and the process is generally slower.
157+
# from .docker import get_lllc_container_id
158+
# result = subprocess.run(
159+
# ["docker", "exec", get_lllc_container_id(), "lllc", tmp_path[5:]],
160+
# capture_output=True,
161+
# text=True,
162+
# )
163+
compiled_code = "".join(result.stdout.splitlines())
164+
else:
165+
raise Exception(f'Error parsing code: "{self.code_raw}"')
166+
167+
try:
168+
return bytes.fromhex(compiled_code)
169+
except ValueError as e:
170+
raise Exception(f'Error parsing compile code: "{self.code_raw}"') from e
171+
172+
173+
def parse_code_label(code) -> CodeInFillerSource:
174+
"""Parse label from code."""
75175
label_marker = ":label"
76176
label_index = code.find(label_marker)
77-
yul_marker = ":yul"
78-
yul_index = code.find(yul_marker)
79177

80178
# Parse :label into code options
179+
label = None
81180
if label_index != -1:
82181
space_index = code.find(" ", label_index + len(label_marker) + 1)
83182
if space_index == -1:
84183
label = code[label_index + len(label_marker) + 1 :]
85184
else:
86185
label = code[label_index + len(label_marker) + 1 : space_index]
87-
code_options.label = label
88-
89-
# Prase :raw
90-
if raw_index != -1:
91-
compiled_code = code[raw_index + len(raw_marker) :]
92-
93-
elif yul_index != -1:
94-
option_start = yul_index + len(yul_marker)
95-
options: list[str] = []
96-
native_yul_options: str = ""
97-
98-
if code[option_start:].lstrip().startswith("{"):
99-
# No yul options, proceed to code parsing
100-
source_start = option_start
101-
else:
102-
opt, source_start = parse_args_from_string_into_array(code, option_start + 1)
103-
for arg in opt:
104-
if arg == "object" or arg == '"C"':
105-
native_yul_options += arg + " "
106-
else:
107-
options.append(arg)
108-
109-
with tempfile.NamedTemporaryFile(mode="w+", delete=False) as tmp:
110-
tmp.write(native_yul_options + code[source_start:])
111-
tmp_path = tmp.name
112-
compiled_code = compile_yul(
113-
source_file=tmp_path,
114-
evm_version=options[0] if len(options) >= 1 else None,
115-
optimize=options[1] if len(options) >= 2 else None,
116-
)[2:]
117-
118-
# Prase :abi
119-
elif abi_index != -1:
120-
abi_encoding = code[abi_index + len(abi_marker) + 1 :]
121-
tokens = abi_encoding.strip().split()
122-
abi = tokens[0]
123-
function_signature = function_signature_to_4byte_selector(abi)
124-
parameter_str = re.sub(r"^\w+", "", abi).strip()
125-
126-
parameter_types = parameter_str.strip("()").split(",")
127-
if len(tokens) > 1:
128-
function_parameters = encode(
129-
[parameter_str],
130-
[
131-
[
132-
int(t.lower(), 0) & ((1 << 256) - 1) # treat big ints as 256bits
133-
if parameter_types[t_index] == "uint"
134-
else int(t.lower(), 0) > 0 # treat positive values as True
135-
if parameter_types[t_index] == "bool"
136-
else False and ValueError("unhandled parameter_types")
137-
for t_index, t in enumerate(tokens[1:])
138-
]
139-
],
140-
)
141-
return (function_signature + function_parameters, code_options)
142-
return (function_signature, code_options)
143-
144-
# Prase plain code 0x
145-
elif code.lstrip().startswith("0x"):
146-
compiled_code = code[2:].lower()
147-
148-
# Prase lllc code
149-
elif code.lstrip().startswith("{") or code.lstrip().startswith("(asm"):
150-
binary_path = "lllc"
151-
with tempfile.NamedTemporaryFile(mode="w+", delete=False) as tmp:
152-
tmp.write(code)
153-
tmp_path = tmp.name
154-
result = subprocess.run([binary_path, tmp_path], capture_output=True, text=True)
155-
compiled_code = "".join(result.stdout.splitlines())
156-
else:
157-
raise Exception(f'Error parsing code: "{code}"')
158-
159-
try:
160-
return (bytes.fromhex(compiled_code), code_options)
161-
except ValueError as e:
162-
raise Exception(f'Error parsing compile code: "{code}"') from e
186+
return CodeInFillerSource(code, label)
163187

164188

165189
AddressInFiller = Annotated[Address, BeforeValidator(lambda a: Address(a, left_padding=True))]
166190
ValueInFiller = Annotated[HexNumber, BeforeValidator(parse_hex_number)]
167-
CodeInFiller = Annotated[Tuple[Bytes, CodeOptions], BeforeValidator(parse_code)]
191+
CodeInFiller = Annotated[CodeInFillerSource, BeforeValidator(parse_code_label)]
168192
Hash32InFiller = Annotated[Hash, BeforeValidator(lambda h: Hash(h, left_padding=True))]
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
"""Manage lllc docker container."""
2+
3+
import subprocess
4+
import threading
5+
6+
# Global variable to store the container id once instantiated.
7+
container_id = None
8+
9+
# A global lock to control concurrent access.
10+
container_lock = threading.Lock()
11+
12+
13+
def get_lllc_container_id():
14+
"""
15+
Return the container ID. If the container is not yet instantiated,
16+
it acquires the lock and runs the Docker command to instantiate the container.
17+
"""
18+
global container_id
19+
# Acquire the lock so that only one thread can instantiate the container.
20+
with container_lock:
21+
if container_id is None:
22+
try:
23+
# Run the docker command using subprocess. The command is equivalent to:
24+
# docker run -d --entrypoint tail -v /tmp:/tests -w /tests lllc -f /dev/null
25+
result = subprocess.run(
26+
[
27+
"docker",
28+
"run",
29+
"-d",
30+
"--entrypoint",
31+
"tail",
32+
"-v",
33+
"/tmp:/tests",
34+
"-w",
35+
"/tests",
36+
"lllc",
37+
"-f",
38+
"/dev/null",
39+
],
40+
check=True,
41+
stdout=subprocess.PIPE,
42+
stderr=subprocess.PIPE,
43+
text=True,
44+
)
45+
# The container id is expected to be printed to stdout.
46+
container_id = result.stdout.strip()
47+
print(f"Container instantiated with id: {container_id}")
48+
except subprocess.CalledProcessError as e:
49+
# In case of error, print the error message and raise the exception.
50+
raise Exception("Error instantiating container:", e.stderr) from e
51+
return container_id
52+
53+
54+
def stop_lllc_containers():
55+
"""Stop all running Docker containers that were started from the 'lllc' image."""
56+
try:
57+
# Retrieve container IDs for all running containers with the image 'lllc'
58+
result = subprocess.check_output(
59+
["docker", "ps", "-q", "--filter", "ancestor=lllc"], text=True
60+
)
61+
container_ids = result.strip().splitlines()
62+
63+
if not container_ids:
64+
print("No running containers for image 'lllc' found.")
65+
return
66+
67+
# Iterate over each container ID and stop it.
68+
for cid in container_ids:
69+
subprocess.run(["docker", "stop", cid], check=True)
70+
71+
except subprocess.CalledProcessError as e:
72+
raise Exception("Error while stopping containers:", e.stderr) from e

src/ethereum_test_specs/static_state/expect_section.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ class Config:
3838
"""Model Config."""
3939

4040
extra = "forbid"
41+
arbitrary_types_allowed = True # For CodeInFiller
4142

4243

4344
class CMP(Enum):

src/ethereum_test_specs/static_state/general_transaction.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ class Config:
1919
"""Model Config."""
2020

2121
extra = "forbid"
22+
arbitrary_types_allowed = True # For CodeInFiller
2223

2324
@field_validator("access_list", mode="before")
2425
def convert_keys_to_hash(cls, access_list): # noqa: N805

0 commit comments

Comments
 (0)