Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 7 additions & 7 deletions .github/configs/feature.yaml
Original file line number Diff line number Diff line change
@@ -1,27 +1,27 @@
# Unless filling for special features, all features should fill for previous forks (starting from Frontier) too
stable:
evm-type: stable
fill-params: --until=Prague --fill-static-tests --ignore=tests/static/state_tests/stQuadraticComplexityTest
fill-params: --no-html --until=Prague --fill-static-tests --ignore=tests/static/state_tests/stQuadraticComplexityTest

develop:
evm-type: develop
fill-params: --until=BPO4 --fill-static-tests --ignore=tests/static/state_tests/stQuadraticComplexityTest
evm-type: develop
fill-params: --no-html --until=BPO4 --fill-static-tests --ignore=tests/static/state_tests/stQuadraticComplexityTest

benchmark:
evm-type: benchmark
fill-params: --fork=Prague --gas-benchmark-values 1,5,10,30,60,100,150 -m benchmark ./tests/benchmark
fill-params: --no-html --fork=Prague --gas-benchmark-values 1,5,10,30,60,100,150 -m benchmark ./tests/benchmark

benchmark_develop:
evm-type: benchmark
fill-params: --fork=Osaka --gas-benchmark-values 1,5,10,30,60,100,150 -m "benchmark" ./tests/benchmark
fill-params: --no-html --fork=Osaka --gas-benchmark-values 1,5,10,30,60,100,150 -m "benchmark" ./tests/benchmark
feature_only: true

benchmark_fast:
evm-type: benchmark
fill-params: --fork=Prague --gas-benchmark-values 100 -m "benchmark" ./tests/benchmark
fill-params: --no-html --fork=Prague --gas-benchmark-values 100 -m "benchmark" ./tests/benchmark
feature_only: true

bal:
evm-type: develop
fill-params: --fork=Amsterdam --fill-static-tests
fill-params: --no-html --fork=Amsterdam --fill-static-tests
feature_only: true
74 changes: 74 additions & 0 deletions packages/testing/src/execution_testing/cli/gen_index.py
Original file line number Diff line number Diff line change
Expand Up @@ -226,5 +226,79 @@ def generate_fixtures_index(
f.write(index.model_dump_json(exclude_none=False, indent=2))


def merge_partial_indexes(output_dir: Path, quiet_mode: bool = False) -> None:
"""
Merge partial index files from all workers into final index.json.

This is called by pytest_sessionfinish on the master process after all
workers have finished and written their partial indexes.

Partial indexes use JSONL format (one JSON object per line) for efficient
append-only writes during fill. Entries are validated with Pydantic here.

Args:
output_dir: The fixture output directory.
quiet_mode: If True, don't print status messages.

"""
meta_dir = output_dir / ".meta"
partial_files = list(meta_dir.glob("partial_index*.jsonl"))

if not partial_files:
raise Exception("No partial indexes found.")

# Merge all partial indexes (JSONL format: one entry per line)
# Read as raw dicts — the data was already validated when collected
# from live Pydantic fixture objects in add_fixture().
all_raw_entries: list[dict] = []
all_forks: set = set()
all_formats: set = set()

for partial_file in partial_files:
with open(partial_file) as f:
for line in f:
line = line.strip()
if not line:
continue
entry_data = json.loads(line)
all_raw_entries.append(entry_data)
# Collect forks and formats from raw strings
if entry_data.get("fork"):
all_forks.add(entry_data["fork"])
if entry_data.get("format"):
all_formats.add(entry_data["format"])

# Compute root hash from raw dicts (no Pydantic needed)
root_hash = HashableItem.from_raw_entries(all_raw_entries).hash()

# Build final index — Pydantic validates the entire structure once
# via model_validate(), not 96k individual model_validate() calls.
index = IndexFile.model_validate(
{
"test_cases": all_raw_entries,
"root_hash": HexNumber(root_hash),
"created_at": datetime.datetime.now(),
"test_count": len(all_raw_entries),
"forks": list(all_forks),
"fixture_formats": list(all_formats),
}
)

# Write final index
index_path = meta_dir / "index.json"
index_path.parent.mkdir(parents=True, exist_ok=True)
index_path.write_text(index.model_dump_json(exclude_none=True, indent=2))

if not quiet_mode:
rich.print(
f"[green]Merged {len(partial_files)} partial indexes "
f"({len(all_raw_entries)} test cases) into {index_path}[/]"
)

# Cleanup partial files
for partial_file in partial_files:
partial_file.unlink()


if __name__ == "__main__":
generate_fixtures_index_cli()
105 changes: 104 additions & 1 deletion packages/testing/src/execution_testing/cli/hasher.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,22 @@
"""Simple CLI tool to hash a directory of JSON fixtures."""

from __future__ import annotations

import hashlib
import json
import sys
from dataclasses import dataclass, field
from enum import IntEnum, auto
from pathlib import Path
from typing import Any, Callable, Dict, List, Optional, TypeVar
from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, TypeVar

import click
from rich.console import Console
from rich.markup import escape as rich_escape

if TYPE_CHECKING:
from execution_testing.fixtures.consume import TestCaseIndexFile


class HashableItemType(IntEnum):
"""Represents the type of a hashable item."""
Expand Down Expand Up @@ -145,6 +150,104 @@ def from_folder(
items[file_path.name] = item
return cls(type=HashableItemType.FOLDER, items=items, parents=parents)

@classmethod
def from_index_entries(
cls, entries: List["TestCaseIndexFile"]
) -> "HashableItem":
"""
Create a hashable item tree from index entries (no file I/O).

This produces the same hash as from_folder() but uses pre-collected
fixture hashes instead of reading files from disk.

Optimized to O(n) using a trie-like structure built in a single pass,
avoiding repeated path operations and iterations.
"""
raw = [
{
"id": e.id,
"json_path": str(e.json_path),
"fixture_hash": str(e.fixture_hash)
if e.fixture_hash
else None,
}
for e in entries
]
return cls.from_raw_entries(raw)

@classmethod
def from_raw_entries(cls, entries: List[Dict]) -> "HashableItem":
"""
Create a hashable item tree from raw entry dicts (no file I/O).

Accepts dicts with "id", "json_path", and "fixture_hash" keys.
This avoids Pydantic overhead entirely — only plain string/int
operations are used to build the hash tree.

Produces the same hash as from_folder() and from_index_entries().
"""
# Build a trie where each node is either:
# - A dict (folder node) containing child nodes
# - A list of (test_id, hash_bytes) tuples (file node marker)
#
# Structure: {folder: {folder: {file.json: [(id, hash), ...]}}}
root_trie: dict = {}

# Single pass: insert all entries into trie
for entry in entries:
fixture_hash = entry.get("fixture_hash")
if not fixture_hash:
continue

# Navigate/create path to file node
path_parts = Path(entry["json_path"]).parts
current = root_trie

# Navigate to parent folder, creating nodes as needed
for part in path_parts[:-1]:
if part not in current:
current[part] = {}
current = current[part]

# Add test entry to file node
file_name = path_parts[-1]
if file_name not in current:
current[file_name] = []

# Convert hex string to 32-byte hash
hash_bytes = int(fixture_hash, 16).to_bytes(32, "big")
current[file_name].append((entry["id"], hash_bytes))

# Convert trie to HashableItem tree (single recursive pass)
def trie_to_hashable(node: dict) -> Dict[str, "HashableItem"]:
"""Convert a trie node to HashableItem dict."""
items: Dict[str, HashableItem] = {}

for name, child in node.items():
if isinstance(child, list):
# File node: child is list of (test_id, hash_bytes)
test_items = {
test_id: cls(
type=HashableItemType.TEST, root=hash_bytes
)
for test_id, hash_bytes in child
}
items[name] = cls(
type=HashableItemType.FILE, items=test_items
)
else:
# Folder node: recurse
items[name] = cls(
type=HashableItemType.FOLDER,
items=trie_to_hashable(child),
)

return items

return cls(
type=HashableItemType.FOLDER, items=trie_to_hashable(root_trie)
)


def render_hash_report(
folder: Path,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
ReferenceSpec,
)
from execution_testing.cli.gen_index import (
generate_fixtures_index,
merge_partial_indexes,
)
from execution_testing.client_clis import TransitionTool
from execution_testing.client_clis.clis.geth import FixtureConsumerTool
Expand All @@ -44,6 +44,7 @@
PreAllocGroupBuilders,
PreAllocGroups,
TestInfo,
merge_partial_fixture_files,
)
from execution_testing.forks import (
Fork,
Expand Down Expand Up @@ -1237,11 +1238,16 @@ def fixture_collector(
single_fixture_per_file=fixture_output.single_fixture_per_file,
filler_path=filler_path,
base_dump_dir=base_dump_dir,
generate_index=request.config.getoption("generate_index"),
)
yield fixture_collector
fixture_collector.dump_fixtures()
worker_id = os.environ.get("PYTEST_XDIST_WORKER", None)
fixture_collector.dump_fixtures(worker_id)
if do_fixture_verification:
fixture_collector.verify_fixture_files(evm_fixture_verification)
# Write partial index for this worker/scope
if fixture_collector.generate_index:
fixture_collector.write_partial_index(worker_id)


@pytest.fixture(autouse=True, scope="session")
Expand Down Expand Up @@ -1589,6 +1595,19 @@ def pytest_collection_modifyitems(
for i in reversed(items_for_removal):
items.pop(i)

# Schedule slow-marked tests first (Longest Processing Time First).
# Workers each grab the next test from the queue, so slow tests get
# distributed across workers and finish before the fast-test tail.
slow_items = []
normal_items = []
for item in items:
if item.get_closest_marker("slow") is not None:
slow_items.append(item)
else:
normal_items.append(item)
if slow_items:
items[:] = slow_items + normal_items


def pytest_sessionfinish(session: pytest.Session, exitstatus: int) -> None:
"""
Expand Down Expand Up @@ -1630,18 +1649,24 @@ def pytest_sessionfinish(session: pytest.Session, exitstatus: int) -> None:
if fixture_output.is_stdout or is_help_or_collectonly_mode(session.config):
return

# Merge partial fixture files from all workers into final JSON files
merge_partial_fixture_files(fixture_output.directory)

# Remove any lock files that may have been created.
for file in fixture_output.directory.rglob("*.lock"):
file.unlink()

# Generate index file for all produced fixtures.
# Generate index file for all produced fixtures by merging partial indexes.
# Only merge if partial indexes were actually written (i.e., tests produced
# fixtures). When no tests are filled (e.g., all skipped), no partial
# indexes exist and merge_partial_indexes should not be called.
if (
session.config.getoption("generate_index")
and not session_instance.phase_manager.is_pre_alloc_generation
):
generate_fixtures_index(
fixture_output.directory, quiet_mode=True, force_flag=False
)
meta_dir = fixture_output.directory / ".meta"
if meta_dir.exists() and any(meta_dir.glob("partial_index*.jsonl")):
merge_partial_indexes(fixture_output.directory, quiet_mode=True)

# Create tarball of the output directory if the output is a tarball.
fixture_output.create_tarball()
Loading
Loading