1
- """A pytest plugin providing common functionality for consuming test fixtures."""
1
+ """
2
+ A pytest plugin providing common functionality for consuming test fixtures.
2
3
4
+ Features:
5
+ - Downloads and caches test fixtures from various sources (local, URL, release).
6
+ - Manages test case generation from fixture files.
7
+ - Provides xdist load balancing for large pre-allocation groups (enginex simulator).
8
+ """
9
+
10
+ import logging
3
11
import re
4
12
import sys
5
13
import tarfile
6
14
from dataclasses import dataclass
7
15
from io import BytesIO
8
16
from pathlib import Path
9
- from typing import List , Tuple
17
+ from typing import Dict , List , Tuple
10
18
from urllib .parse import urlparse
11
19
12
20
import platformdirs
15
23
import rich
16
24
17
25
from cli .gen_index import generate_fixtures_index
18
- from ethereum_test_fixtures import BaseFixture
26
+ from ethereum_test_fixtures import BaseFixture , BlockchainEngineXFixture
19
27
from ethereum_test_fixtures .consume import IndexFile , TestCases
20
28
from ethereum_test_forks import get_forks , get_relative_fork_markers , get_transition_forks
21
29
from ethereum_test_tools .utility .versioning import get_current_commit_hash_or_tag
22
30
23
31
from .releases import ReleaseTag , get_release_page_url , get_release_url , is_release_url , is_url
24
32
33
+ logger = logging .getLogger (__name__ )
34
+
25
35
CACHED_DOWNLOADS_DIRECTORY = (
26
36
Path (platformdirs .user_cache_dir ("ethereum-execution-spec-tests" )) / "cached_downloads"
27
37
)
28
38
29
39
40
+ class XDistGroupMapper :
41
+ """
42
+ Maps test cases to xdist groups, splitting large pre-allocation groups into sub-groups.
43
+
44
+ This class helps improve load balancing when using pytest-xdist with --dist=loadgroup
45
+ by breaking up large pre-allocation groups (e.g., 1000+ tests) into smaller virtual
46
+ sub-groups while maintaining the constraint that tests from the same pre-allocation
47
+ group must run on the same worker.
48
+ """
49
+
50
+ def __init__ (self , max_group_size : int = 400 ):
51
+ """Initialize the mapper with a maximum group size."""
52
+ self .max_group_size = max_group_size
53
+ self .group_sizes : Dict [str , int ] = {}
54
+ self .test_to_subgroup : Dict [str , int ] = {}
55
+ self ._built = False
56
+
57
+ def build_mapping (self , test_cases : TestCases ) -> None :
58
+ """
59
+ Build the mapping of test cases to sub-groups.
60
+
61
+ This analyzes all test cases and determines which pre-allocation groups
62
+ need to be split into sub-groups based on the max_group_size.
63
+ """
64
+ if self ._built :
65
+ return
66
+
67
+ # Count tests per pre-allocation group
68
+ for test_case in test_cases :
69
+ if hasattr (test_case , "pre_hash" ) and test_case .pre_hash :
70
+ pre_hash = test_case .pre_hash
71
+ self .group_sizes [pre_hash ] = self .group_sizes .get (pre_hash , 0 ) + 1
72
+
73
+ # Assign sub-groups for large groups
74
+ group_counters : Dict [str , int ] = {}
75
+ for test_case in test_cases :
76
+ if hasattr (test_case , "pre_hash" ) and test_case .pre_hash :
77
+ pre_hash = test_case .pre_hash
78
+ group_size = self .group_sizes [pre_hash ]
79
+
80
+ if group_size <= self .max_group_size :
81
+ # Small group, no sub-group needed
82
+ self .test_to_subgroup [test_case .id ] = 0
83
+ else :
84
+ # Large group, assign to sub-group using round-robin
85
+ counter = group_counters .get (pre_hash , 0 )
86
+ sub_group = counter // self .max_group_size
87
+ self .test_to_subgroup [test_case .id ] = sub_group
88
+ group_counters [pre_hash ] = counter + 1
89
+
90
+ self ._built = True
91
+
92
+ # Log summary of large groups
93
+ large_groups = [
94
+ (pre_hash , size )
95
+ for pre_hash , size in self .group_sizes .items ()
96
+ if size > self .max_group_size
97
+ ]
98
+ if large_groups :
99
+ logger .info (
100
+ f"Found { len (large_groups )} pre-allocation groups larger than "
101
+ f"{ self .max_group_size } tests that will be split for better load balancing"
102
+ )
103
+
104
+ def get_xdist_group_name (self , test_case ) -> str :
105
+ """
106
+ Get the xdist group name for a test case.
107
+
108
+ For small groups, returns the pre_hash as-is.
109
+ For large groups, returns "{pre_hash}:{sub_group_index}".
110
+ """
111
+ if not hasattr (test_case , "pre_hash" ) or not test_case .pre_hash :
112
+ # No pre_hash, use test ID as fallback
113
+ return test_case .id
114
+
115
+ pre_hash = test_case .pre_hash
116
+ group_size = self .group_sizes .get (pre_hash , 0 )
117
+
118
+ if group_size <= self .max_group_size :
119
+ # Small group, use pre_hash as-is
120
+ return pre_hash
121
+
122
+ # Large group, include sub-group index
123
+ sub_group = self .test_to_subgroup .get (test_case .id , 0 )
124
+ return f"{ pre_hash } :{ sub_group } "
125
+
126
+ def get_split_statistics (self ) -> Dict [str , Dict [str , int ]]:
127
+ """
128
+ Get statistics about how groups were split.
129
+
130
+ Returns a dict with information about each pre-allocation group
131
+ and how many sub-groups it was split into.
132
+ """
133
+ stats = {}
134
+ for pre_hash , size in self .group_sizes .items ():
135
+ if size > self .max_group_size :
136
+ num_subgroups = (size + self .max_group_size - 1 ) // self .max_group_size
137
+ stats [pre_hash ] = {
138
+ "total_tests" : size ,
139
+ "num_subgroups" : num_subgroups ,
140
+ "tests_per_subgroup" : size // num_subgroups ,
141
+ }
142
+ return stats
143
+
144
+
30
145
def default_input () -> str :
31
146
"""
32
147
Directory (default) to consume generated test fixtures from. Defined as a
@@ -345,6 +460,29 @@ def pytest_configure(config): # noqa: D103
345
460
index = IndexFile .model_validate_json (index_file .read_text ())
346
461
config .test_cases = index .test_cases
347
462
463
+ # Create XDistGroupMapper for enginex simulator if needed
464
+ if (
465
+ hasattr (config , "_supported_fixture_formats" )
466
+ and BlockchainEngineXFixture .format_name in config ._supported_fixture_formats
467
+ ):
468
+ max_group_size = getattr (config , "enginex_max_group_size" , 400 )
469
+ config .xdist_group_mapper = XDistGroupMapper (max_group_size )
470
+ config .xdist_group_mapper .build_mapping (config .test_cases )
471
+
472
+ # Log statistics about group splitting
473
+ split_stats = config .xdist_group_mapper .get_split_statistics ()
474
+ if split_stats :
475
+ rich .print ("[bold yellow]Pre-allocation group splitting for load balancing:[/]" )
476
+ for pre_hash , stats in split_stats .items ():
477
+ rich .print (
478
+ f" Group { pre_hash [:8 ]} : { stats ['total_tests' ]} tests → "
479
+ f"{ stats ['num_subgroups' ]} sub-groups "
480
+ f"(~{ stats ['tests_per_subgroup' ]} tests each)"
481
+ )
482
+ rich .print (f" Max group size: { max_group_size } " )
483
+ else :
484
+ config .xdist_group_mapper = None
485
+
348
486
for fixture_format in BaseFixture .formats .values ():
349
487
config .addinivalue_line (
350
488
"markers" ,
@@ -416,6 +554,7 @@ def pytest_generate_tests(metafunc):
416
554
return
417
555
418
556
test_cases = metafunc .config .test_cases
557
+ xdist_group_mapper = getattr (metafunc .config , "xdist_group_mapper" , None )
419
558
param_list = []
420
559
for test_case in test_cases :
421
560
if test_case .format .format_name not in metafunc .config ._supported_fixture_formats :
@@ -427,12 +566,23 @@ def pytest_generate_tests(metafunc):
427
566
if hasattr (test_case , "pre_hash" ) and test_case .pre_hash :
428
567
test_id = f"{ test_case .id } [{ test_case .pre_hash [:8 ]} ]"
429
568
569
+ # Determine xdist group name
570
+ if xdist_group_mapper and hasattr (test_case , "pre_hash" ) and test_case .pre_hash :
571
+ # Use the mapper to get potentially split group name
572
+ xdist_group_name = xdist_group_mapper .get_xdist_group_name (test_case )
573
+ elif hasattr (test_case , "pre_hash" ) and test_case .pre_hash :
574
+ # No mapper or not enginex, use pre_hash directly
575
+ xdist_group_name = test_case .pre_hash
576
+ else :
577
+ # No pre_hash, use test ID
578
+ xdist_group_name = test_case .id
579
+
430
580
param = pytest .param (
431
581
test_case ,
432
582
id = test_id ,
433
583
marks = [getattr (pytest .mark , m ) for m in fork_markers ]
434
584
+ [getattr (pytest .mark , test_case .format .format_name )]
435
- + [pytest .mark .xdist_group (name = test_case . pre_hash )],
585
+ + [pytest .mark .xdist_group (name = xdist_group_name )],
436
586
)
437
587
param_list .append (param )
438
588
0 commit comments