Skip to content

Commit 7db10f8

Browse files
committed
Fix mixin arg merge order for lists and scalars (fixes #40)
1 parent ebedd27 commit 7db10f8

2 files changed

Lines changed: 131 additions & 2 deletions

File tree

colcon_mixin/mixin/mixin_argument.py

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -181,16 +181,38 @@ def collect_parsers_by_verb(root, parsers, parent_verbs=()):
181181
# update args based on selected mixins
182182
if 'mixin_verb' in args:
183183
mixins = mixins_by_verb.get(args.mixin_verb, {})
184+
context = '.'.join(args.mixin_verb)
185+
186+
# validate all mixins first
184187
for mixin in args.mixin or ():
185188
if mixin not in mixins:
186-
context = '.'.join(args.mixin_verb)
187189
self._parser.error(
188190
"Mixin '{mixin}' is not available for '{context}'"
189191
.format_map(locals()))
192+
193+
# pre-merge all mixin args in order before applying to final args:
194+
# - lists are concatenated in mixin order (first mixin's values
195+
# come first)
196+
# - scalars: later mixin overrides earlier mixin (last wins)
197+
# this avoids the bug where iterative application causes lists to
198+
# be reversed and scalars from later mixins to be silently skipped
199+
merged_mixin_args = {}
200+
for mixin in args.mixin or ():
190201
mixin_args = mixins[mixin]
191202
logger.debug(
192203
"Using mixin '{mixin}': {mixin_args}".format_map(locals()))
193-
self._update_args(args, mixin_args, '.'.join(args.mixin_verb))
204+
for key, value in mixin_args.items():
205+
if key not in merged_mixin_args:
206+
merged_mixin_args[key] = value
207+
elif isinstance(value, list):
208+
merged_mixin_args[key] = merged_mixin_args[key] + value
209+
else:
210+
# later mixin overrides earlier mixin for scalars
211+
merged_mixin_args[key] = value
212+
213+
if merged_mixin_args:
214+
self._update_args(
215+
args, merged_mixin_args, context)
194216

195217
# undo default value wrapping injected in the add_argument() method
196218
for k, v in args.__dict__.items():

test/test_mixin_merge_order.py

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
# Copyright 2024 colcon contributors
2+
# Licensed under the Apache License, Version 2.0
3+
4+
"""
5+
Regression tests for mixin merge order (Issue #40).
6+
7+
When using --mixin a b:
8+
- list arguments should be concatenated in the given order (a's values
9+
first, then b's values)
10+
- scalar arguments should be overridden by the later mixin (b wins over a)
11+
"""
12+
13+
14+
def _pre_merge(mixins, mixin_names):
15+
"""Pre-merge mixin args in order — mirrors the fix in mixin_argument.py."""
16+
merged = {}
17+
for name in mixin_names:
18+
for key, value in mixins[name].items():
19+
if key not in merged:
20+
merged[key] = value
21+
elif isinstance(value, list):
22+
merged[key] = merged[key] + value
23+
else:
24+
merged[key] = value
25+
return merged
26+
27+
28+
class TestMixinMergeOrder:
29+
30+
def test_list_args_concatenated_in_mixin_order(self):
31+
"""
32+
Lists from --mixin a b should appear as [a_values, b_values].
33+
34+
Previously they were reversed: [b_values, a_values].
35+
Regression test for Issue #40.
36+
"""
37+
mixins = {
38+
'a': {'list-arg': ['val-a']},
39+
'b': {'list-arg': ['val-b']},
40+
}
41+
merged = _pre_merge(mixins, ['a', 'b'])
42+
assert merged['list-arg'] == ['val-a', 'val-b']
43+
44+
def test_scalar_args_later_mixin_wins(self):
45+
"""
46+
For scalar args, --mixin a b should result in b's value winning.
47+
48+
Previously a's value was set first and b's was silently skipped.
49+
Regression test for Issue #40.
50+
"""
51+
mixins = {
52+
'a': {'scalar-arg': 'val-a'},
53+
'b': {'scalar-arg': 'val-b'},
54+
}
55+
merged = _pre_merge(mixins, ['a', 'b'])
56+
assert merged['scalar-arg'] == 'val-b'
57+
58+
def test_mixed_list_and_scalar_together(self):
59+
"""
60+
Both fixes should work together in a realistic scenario.
61+
62+
Mixin a and b both define a list arg and a scalar arg.
63+
"""
64+
mixins = {
65+
'a': {'list-arg': ['val-a'], 'scalar-arg': 'val-a'},
66+
'b': {'list-arg': ['val-b'], 'scalar-arg': 'val-b'},
67+
}
68+
merged = _pre_merge(mixins, ['a', 'b'])
69+
assert merged['list-arg'] == ['val-a', 'val-b']
70+
assert merged['scalar-arg'] == 'val-b'
71+
72+
def test_single_mixin_unaffected(self):
73+
"""Single mixin usage should behave exactly as before."""
74+
mixins = {
75+
'a': {'list-arg': ['val-a'], 'scalar-arg': 'val-a'},
76+
}
77+
merged = _pre_merge(mixins, ['a'])
78+
assert merged['list-arg'] == ['val-a']
79+
assert merged['scalar-arg'] == 'val-a'
80+
81+
def test_three_mixins_list_order_preserved(self):
82+
"""
83+
Order should be preserved across more than two mixins.
84+
85+
--mixin a b c should produce [a_val, b_val, c_val].
86+
"""
87+
mixins = {
88+
'a': {'list-arg': ['val-a']},
89+
'b': {'list-arg': ['val-b']},
90+
'c': {'list-arg': ['val-c']},
91+
}
92+
merged = _pre_merge(mixins, ['a', 'b', 'c'])
93+
assert merged['list-arg'] == ['val-a', 'val-b', 'val-c']
94+
95+
def test_three_mixins_scalar_last_wins(self):
96+
"""
97+
With three mixins, the last one defining a scalar should win.
98+
99+
--mixin a b c should give c's scalar value.
100+
"""
101+
mixins = {
102+
'a': {'scalar-arg': 'val-a'},
103+
'b': {'scalar-arg': 'val-b'},
104+
'c': {'scalar-arg': 'val-c'},
105+
}
106+
merged = _pre_merge(mixins, ['a', 'b', 'c'])
107+
assert merged['scalar-arg'] == 'val-c'

0 commit comments

Comments
 (0)