Skip to content

Commit b21c1f5

Browse files
authored
MOD-14486: Fix module args handling for odd number of words and flexible merging (#245)
* try merging instread * use strip
1 parent 0f50113 commit b21c1f5

2 files changed

Lines changed: 86 additions & 74 deletions

File tree

RLTest/utils.py

Lines changed: 46 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -98,47 +98,66 @@ def dicty(args):
9898
def join_lists(lists):
9999
return list(itertools.chain.from_iterable(lists))
100100

101+
def _merge_by_words(explicit_str, defaultArgs):
102+
"""Merge a plain explicit arg string with defaults using word-level key matching.
103+
For each default arg, if its key doesn't appear as a word in the explicit string,
104+
append the entire default arg to the string.
105+
Returns the merged string wrapped as [[merged_string]].
106+
"""
107+
if not defaultArgs or not defaultArgs[0]:
108+
return [[explicit_str]]
109+
explicit_words_upper = [w.upper() for w in explicit_str.split()]
110+
merged = explicit_str
111+
for arg in defaultArgs[0]:
112+
key = arg.split()[0].upper()
113+
if key not in explicit_words_upper:
114+
merged += ' ' + arg
115+
return [[merged.strip()]]
116+
117+
def _merge_by_dict(modulesArgs, defaultArgs):
118+
"""Merge structured (already-split) modulesArgs with defaults using dict-based key matching.
119+
For each module, any default key not present in the explicit args is appended.
120+
"""
121+
modules_args_dict = args_list_to_dict(modulesArgs)
122+
for imod, args_list in enumerate(defaultArgs):
123+
for arg in args_list:
124+
name = arg.split(' ')[0].upper()
125+
if name not in modules_args_dict[imod]:
126+
modulesArgs[imod] += [arg]
127+
return modulesArgs
128+
101129
def fix_modulesArgs(modules, modulesArgs, defaultArgs=None, haveSeqs=True):
102130
# modulesArgs is one of the following:
103131
# None
104132
# 'args ...': arg string for a single module
105133
# ['args ...', ...]: arg list for a single module
106134
# [['arg', ...', ...], ...]: arg strings for multiple modules
107135

108-
# arg string is a string of words separated by whitespace.
109-
# arg string can be separated by semicolons into (logical) arg lists.
110-
# semicolons can be escaped with a backslash.
111-
# if no semicolons are present, the string is treated as space-separated key-value pairs,
112-
# where each consecutive pair of words forms a 'KEY VALUE' arg.
113-
# thus, 'K1 V1 K2 V2' becomes ['K1 V1', 'K2 V2']
114-
# an odd number of words without semicolons is an error.
115-
# for args with multiple values, semicolons are required:
116-
# thus, 'K1 V1; K2 V2 V3' becomes ['K1 V1', 'K2 V2 V3']
117-
# arg list is a list of arg strings.
118-
# arg list starts with an arg name that can later be used for argument overriding.
136+
# For a plain string without semicolons:
137+
# If defaultArgs exist, merge by checking if each default key appears as
138+
# a word in the explicit string. Missing defaults are appended.
139+
# If no defaultArgs, keep the string as-is (no splitting needed).
140+
# For strings with semicolons, split by semicolons and use dict-based merge.
141+
# For list inputs, use dict-based merge.
142+
143+
is_plain_str = False # tracks if input was a plain string without semicolons
119144

120145
if type(modulesArgs) == str:
121-
# case # 'args ...': arg string for a single module
122-
# transformed into [['arg', ...]]
123146
parts = split_by_semicolon(modulesArgs)
124147
if len(parts) == 1:
125-
# No semicolons found - treat as space-separated key-value pairs
126-
words = parts[0].split()
127-
if len(words) % 2 != 0:
128-
print(Colors.Bred(f"Error in args: odd number of words in key-value pairs: '{modulesArgs}'. "
129-
f"Use semicolons to separate args with multiple values (e.g. 'KEY1 V1; KEY2 V2 V3')."))
130-
sys.exit(1)
131-
if len(words) > 2:
132-
parts = [f"{words[i]} {words[i + 1]}" for i in range(0, len(words), 2)]
133-
modulesArgs = [parts]
148+
# No semicolons - keep as plain string
149+
is_plain_str = True
150+
modulesArgs = [[modulesArgs.strip()]]
151+
else:
152+
# Has semicolons - already split
153+
modulesArgs = [parts]
134154
elif type(modulesArgs) == list:
135155
args = []
136156
is_list = False
137157
is_str = False
138158
for argx in modulesArgs:
139159
if type(argx) == list:
140160
# case [['arg', ...], ...]: arg strings for multiple modules
141-
# already transformed into [['arg', ...], ...]
142161
if is_str:
143162
print(Colors.Bred('Error in args: %s' % str(modulesArgs)))
144163
sys.exit(1)
@@ -150,7 +169,6 @@ def fix_modulesArgs(modules, modulesArgs, defaultArgs=None, haveSeqs=True):
150169
args += [argx]
151170
else:
152171
# case ['args ...', ...]: arg list for a single module
153-
# transformed into [['arg', ...], ...]
154172
if is_list:
155173
print(Colors.Bred('Error in args: %s' % str(modulesArgs)))
156174
sys.exit(1)
@@ -183,19 +201,13 @@ def fix_modulesArgs(modules, modulesArgs, defaultArgs=None, haveSeqs=True):
183201
return modulesArgs
184202

185203
# if there are fewer defaultArgs than modulesArgs, we should bail out
186-
# as we cannot pad the defaults with emply arg lists
187204
if defaultArgs and len(modulesArgs) > len(defaultArgs):
188205
print(Colors.Bred('Number of module args sets in Env does not match number of modules'))
189206
print(defaultArgs)
190207
print(modulesArgs)
191208
sys.exit(1)
192209

193-
# for each module, sync defaultArgs to modulesARgs
194-
modules_args_dict = args_list_to_dict(modulesArgs)
195-
for imod, args_list in enumerate(defaultArgs):
196-
for arg in args_list:
197-
name = arg.split(' ')[0].upper()
198-
if name not in modules_args_dict[imod]:
199-
modulesArgs[imod] += [arg]
200-
201-
return modulesArgs
210+
if is_plain_str:
211+
return _merge_by_words(modulesArgs[0][0], defaultArgs)
212+
else:
213+
return _merge_by_dict(modulesArgs, defaultArgs)

tests/unit/test_utils.py

Lines changed: 40 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -5,41 +5,39 @@
55

66
class TestFixModulesArgs(TestCase):
77

8-
# 1. Single key-value pair string
8+
# 1. Single key-value pair string, no defaults - kept as single string
99
def test_single_key_value_pair(self):
1010
result = fix_modulesArgs(['/mod.so'], 'WORKERS 4')
1111
self.assertEqual(result, [['WORKERS 4']])
1212

13-
# 2. Multiple key-value pairs without semicolons (new behavior)
14-
def test_multiple_kv_pairs_no_semicolons(self):
13+
# 2. Multiple key-value pairs without semicolons, no defaults - kept as single string
14+
def test_multiple_kv_pairs_no_semicolons_no_defaults(self):
1515
result = fix_modulesArgs(['/mod.so'], '_FREE_RESOURCE_ON_THREAD FALSE TIMEOUT 80 WORKERS 4')
16-
self.assertEqual(result, [['_FREE_RESOURCE_ON_THREAD FALSE', 'TIMEOUT 80', 'WORKERS 4']])
16+
self.assertEqual(result, [['_FREE_RESOURCE_ON_THREAD FALSE TIMEOUT 80 WORKERS 4']])
1717

1818
# 3. Semicolon-separated args (existing behavior)
1919
def test_semicolon_separated_args(self):
2020
result = fix_modulesArgs(['/mod.so'], 'KEY1 V1; KEY2 V2')
2121
self.assertEqual(result, [['KEY1 V1', 'KEY2 V2']])
2222

23-
# 4a. Odd number of words without semicolons - should error
24-
def test_odd_words_no_semicolons_exits(self):
25-
with self.assertRaises(SystemExit):
26-
fix_modulesArgs(['/mod.so'], 'FLAG TIMEOUT 80')
23+
# 4. Odd number of words without semicolons, no defaults - kept as single string, no error
24+
def test_odd_words_no_semicolons_no_error(self):
25+
result = fix_modulesArgs(['/mod.so'], 'FLAG TIMEOUT 80 ')
26+
self.assertEqual(result, [['FLAG TIMEOUT 80']])
2727

2828
# 4b. Odd number of words with semicolons - valid, semicolons split first
2929
def test_odd_words_with_semicolons_valid(self):
3030
result = fix_modulesArgs(['/mod.so'], 'FLAG; TIMEOUT 80')
3131
self.assertEqual(result, [['FLAG', 'TIMEOUT 80']])
3232

33-
# 5a. Space-separated string overrides matching defaults, non-matching defaults added
34-
def test_space_separated_overrides_defaults(self):
33+
# 5a. Plain string with defaults - word-based merge, missing defaults appended
34+
def test_plain_string_overrides_defaults(self):
3535
defaults = [['WORKERS 8', 'TIMEOUT 60', 'EXTRA 1']]
3636
result = fix_modulesArgs(['/mod.so'], 'WORKERS 4 TIMEOUT 80', defaults)
37-
result_dict = {arg.split(' ')[0]: arg for arg in result[0]}
38-
self.assertEqual(result_dict['WORKERS'], 'WORKERS 4')
39-
self.assertEqual(result_dict['TIMEOUT'], 'TIMEOUT 80')
40-
self.assertEqual(result_dict['EXTRA'], 'EXTRA 1')
37+
# Result is a single merged string
38+
self.assertEqual(result, [['WORKERS 4 TIMEOUT 80 EXTRA 1']])
4139

42-
# 5b. Semicolon-separated string overrides matching defaults
40+
# 5b. Semicolon-separated string overrides matching defaults (dict-based merge)
4341
def test_semicolon_separated_overrides_defaults(self):
4442
defaults = [['WORKERS 8', 'TIMEOUT 60', 'EXTRA 1']]
4543
result = fix_modulesArgs(['/mod.so'], 'WORKERS 4; TIMEOUT 80', defaults)
@@ -48,14 +46,11 @@ def test_semicolon_separated_overrides_defaults(self):
4846
self.assertEqual(result_dict['TIMEOUT'], 'TIMEOUT 80')
4947
self.assertEqual(result_dict['EXTRA'], 'EXTRA 1')
5048

51-
# 5c. Space-separated explicit overrides some defaults, non-overlapping defaults are merged
52-
def test_space_separated_partial_override_with_defaults(self):
49+
# 5c. Plain string partial override - missing defaults appended
50+
def test_plain_string_partial_override_with_defaults(self):
5351
defaults = [['_FREE_RESOURCE_ON_THREAD TRUE', 'TIMEOUT 100', 'WORKERS 8']]
5452
result = fix_modulesArgs(['/mod.so'], 'WORKERS 4 TIMEOUT 80', defaults)
55-
result_dict = {arg.split(' ')[0]: arg for arg in result[0]}
56-
self.assertEqual(result_dict['WORKERS'], 'WORKERS 4')
57-
self.assertEqual(result_dict['TIMEOUT'], 'TIMEOUT 80')
58-
self.assertEqual(result_dict['_FREE_RESOURCE_ON_THREAD'], '_FREE_RESOURCE_ON_THREAD TRUE')
53+
self.assertEqual(result, [['WORKERS 4 TIMEOUT 80 _FREE_RESOURCE_ON_THREAD TRUE']])
5954

6055
# 6. None input with defaults - deep copy of defaults
6156
def test_none_uses_defaults(self):
@@ -66,7 +61,7 @@ def test_none_uses_defaults(self):
6661
result[0][0] = 'MODIFIED'
6762
self.assertEqual(defaults[0][0], 'WORKERS 8')
6863

69-
# 7. List of strings with defaults - overlapping and non-overlapping keys
64+
# 7. List of strings with defaults - dict-based merge
7065
def test_list_of_strings_with_defaults(self):
7166
defaults = [['K1 default1', 'K2 default2', 'K4 default4']]
7267
result = fix_modulesArgs(['/mod.so'], ['K1 override1', 'K2 override2', 'K3 new3'], defaults)
@@ -76,35 +71,40 @@ def test_list_of_strings_with_defaults(self):
7671
self.assertEqual(result_dict['K3'], 'K3 new3')
7772
self.assertEqual(result_dict['K4'], 'K4 default4')
7873

79-
# 8. List of lists (multi-module) with defaults - overlapping and non-overlapping keys
74+
# 8. List of lists (multi-module) with defaults - dict-based merge
8075
def test_multi_module_with_defaults(self):
8176
modules = ['/mod1.so', '/mod2.so']
8277
explicit = [['K1 v1', 'K2 v2'], ['K3 v3']]
8378
defaults = [['K1 d1', 'K5 d5'], ['K3 d3', 'K4 d4']]
8479
result = fix_modulesArgs(modules, explicit, defaults)
85-
# Module 1: K1 overridden, K5 added from defaults
8680
dict1 = {arg.split(' ')[0]: arg for arg in result[0]}
8781
self.assertEqual(dict1['K1'], 'K1 v1')
8882
self.assertEqual(dict1['K2'], 'K2 v2')
8983
self.assertEqual(dict1['K5'], 'K5 d5')
90-
# Module 2: K3 overridden, K4 added from defaults
9184
dict2 = {arg.split(' ')[0]: arg for arg in result[1]}
9285
self.assertEqual(dict2['K3'], 'K3 v3')
9386
self.assertEqual(dict2['K4'], 'K4 d4')
9487

88+
# 9. Odd words with defaults - word-based merge, flags and multi-value args handled
89+
def test_odd_words_with_defaults(self):
90+
defaults = [['FORK_GC_CLEAN_NUMERIC_EMPTY_NODES', 'TIMEOUT 90']]
91+
result = fix_modulesArgs(['/mod.so'], 'workers 0 nogc FORK_GC_CLEAN_NUMERIC_EMPTY_NODES timeout 90', defaults)
92+
self.assertEqual(result, [['workers 0 nogc FORK_GC_CLEAN_NUMERIC_EMPTY_NODES timeout 90']])
9593

96-
# 9. Case-insensitive matching between explicit args and defaults (both directions)
97-
def test_case_insensitive_override(self):
98-
# Uppercase explicit overrides lowercase defaults
99-
defaults = [['workers 8', 'timeout 60', 'EXTRA 1', 'MIxEd 7', 'lower true']]
100-
result = fix_modulesArgs(['/mod.so'], 'WORKERS 4 TIMEOUT 80 miXed 0 LOWER false', defaults)
101-
result_dict = {arg.split(' ')[0]: arg for arg in result[0]}
102-
self.assertEqual(result_dict['WORKERS'], 'WORKERS 4')
103-
self.assertEqual(result_dict['TIMEOUT'], 'TIMEOUT 80')
104-
self.assertEqual(result_dict['EXTRA'], 'EXTRA 1')
105-
self.assertEqual(result_dict['miXed'], 'miXed 0')
106-
self.assertEqual(result_dict['LOWER'], 'LOWER false')
107-
self.assertNotIn('workers', result_dict)
108-
self.assertNotIn('timeout', result_dict)
109-
self.assertNotIn('MIxEd', result_dict)
110-
self.assertNotIn('lower', result_dict)
94+
# 10. Plain string with defaults - unknown keys not in defaults stay, missing defaults appended
95+
def test_plain_string_new_keys_with_defaults(self):
96+
defaults = [['TIMEOUT 60']]
97+
result = fix_modulesArgs(['/mod.so'], 'WORKERS 4 TIMEOUT 80', defaults)
98+
self.assertEqual(result, [['WORKERS 4 TIMEOUT 80']])
99+
100+
# 11. Case-insensitive word matching for plain string merge
101+
def test_case_insensitive_word_merge(self):
102+
defaults = [['workers 8', 'TIMEOUT 60', 'EXTRA 1']]
103+
result = fix_modulesArgs(['/mod.so'], 'WORKERS 4 timeout 80', defaults)
104+
self.assertEqual(result, [['WORKERS 4 timeout 80 EXTRA 1']])
105+
106+
# 12. Substring key should not falsely match (GC should not match nogc)
107+
def test_no_substring_match(self):
108+
defaults = [['GC enabled']]
109+
result = fix_modulesArgs(['/mod.so'], 'nogc TIMEOUT 80', defaults)
110+
self.assertEqual(result, [['nogc TIMEOUT 80 GC enabled']])

0 commit comments

Comments
 (0)