Skip to content

Commit c401071

Browse files
committed
Apply mixins in reverse order to preserve command line order (#40)
When multiple mixins provided list arguments (e.g. cmake-args) they were concatenated in reverse of the order given on the command line: each mixin prepends its values, and applying the mixins in forward order stacked them backwards. Apply the mixins in reverse so the resulting list follows the order the mixins were given while keeping any explicit command line arguments last. As a side effect this also lets a later mixin override a scalar value set by an earlier one. The requested mixins are still validated in the given order so an error reports the first unavailable mixin. Add regression tests covering list ordering, scalar override and command line precedence.
1 parent ebedd27 commit c401071

3 files changed

Lines changed: 102 additions & 0 deletions

File tree

colcon_mixin/mixin/mixin_argument.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,12 +181,20 @@ 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+
# validate the requested mixins in the order given on the command
185+
# line so an error reports the first unavailable mixin
184186
for mixin in args.mixin or ():
185187
if mixin not in mixins:
186188
context = '.'.join(args.mixin_verb)
187189
self._parser.error(
188190
"Mixin '{mixin}' is not available for '{context}'"
189191
.format_map(locals()))
192+
# apply the mixins in reverse order: since _update_args() handles
193+
# each mixin by prepending its list values, iterating in reverse
194+
# makes the resulting list follow the order the mixins were given
195+
# on the command line while keeping any explicit command line
196+
# arguments last (see #40)
197+
for mixin in reversed(args.mixin or ()):
190198
mixin_args = mixins[mixin]
191199
logger.debug(
192200
"Using mixin '{mixin}': {mixin_args}".format_map(locals()))

test/spell_check.words

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ argparse
44
basenames
55
basepath
66
blocklist
7+
capsys
8+
cmake
79
colcon
810
completers
911
defaultdict
@@ -17,6 +19,7 @@ plugin
1719
prepending
1820
pydocstyle
1921
pytest
22+
readouterr
2023
rtype
2124
scspell
2225
setuptools

test/test_mixin_argument.py

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
# Copyright 2025 Open Source Robotics Foundation, Inc.
2+
# Licensed under the Apache License, Version 2.0
3+
4+
import argparse
5+
from unittest.mock import patch
6+
7+
from colcon_mixin.mixin.mixin_argument import MixinArgumentDecorator
8+
import pytest
9+
10+
MIXINS = {
11+
'a': {'cmake-args': ['FOO=A']},
12+
'b': {'cmake-args': ['FOO=B']},
13+
'c': {'cmake-args': ['FOO=C']},
14+
}
15+
16+
17+
SCALAR_MIXINS = {
18+
'a': {'build-base': 'BASE_A'},
19+
'b': {'build-base': 'BASE_B'},
20+
}
21+
22+
23+
def _parse(argv, mixins=MIXINS):
24+
base = argparse.ArgumentParser(prog='colcon')
25+
decorator = MixinArgumentDecorator(base)
26+
subparsers = decorator.add_subparsers(dest='verb')
27+
build = subparsers.add_parser('build')
28+
build.add_argument('--cmake-args', nargs='*', default=[])
29+
build.add_argument('--build-base', default='build')
30+
mixins_by_verb = {('build',): mixins}
31+
with patch(
32+
'colcon_mixin.mixin.mixin_argument.get_mixins',
33+
return_value=mixins_by_verb,
34+
):
35+
return decorator.parse_args(argv)
36+
37+
38+
def test_multiple_mixins_preserve_command_line_order():
39+
# regression test for issue #40: the resulting list must follow the
40+
# order the mixins were given on the command line
41+
args = _parse(['build', '--mixin', 'a', 'b'])
42+
assert args.cmake_args == ['FOO=A', 'FOO=B']
43+
44+
45+
def test_multiple_mixins_follow_reversed_selection():
46+
args = _parse(['build', '--mixin', 'b', 'a'])
47+
assert args.cmake_args == ['FOO=B', 'FOO=A']
48+
49+
50+
def test_three_mixins_preserve_order():
51+
args = _parse(['build', '--mixin', 'a', 'b', 'c'])
52+
assert args.cmake_args == ['FOO=A', 'FOO=B', 'FOO=C']
53+
54+
55+
def test_command_line_arguments_take_precedence():
56+
# explicit command line arguments must come last so they win
57+
args = _parse(['build', '--mixin', 'a', 'b', '--cmake-args=FOO=CLI'])
58+
assert args.cmake_args == ['FOO=A', 'FOO=B', 'FOO=CLI']
59+
60+
61+
def test_single_mixin():
62+
args = _parse(['build', '--mixin', 'a'])
63+
assert args.cmake_args == ['FOO=A']
64+
65+
66+
def test_no_mixin():
67+
args = _parse(['build'])
68+
assert args.cmake_args == []
69+
70+
71+
def test_scalar_last_listed_mixin_wins():
72+
# a scalar value from a later mixin must override an earlier one
73+
args = _parse(['build', '--mixin', 'a', 'b'], SCALAR_MIXINS)
74+
assert args.build_base == 'BASE_B'
75+
76+
77+
def test_scalar_reversed_selection():
78+
args = _parse(['build', '--mixin', 'b', 'a'], SCALAR_MIXINS)
79+
assert args.build_base == 'BASE_A'
80+
81+
82+
def test_scalar_command_line_argument_take_precedence():
83+
args = _parse(
84+
['build', '--mixin', 'a', 'b', '--build-base', 'CLI'], SCALAR_MIXINS)
85+
assert args.build_base == 'CLI'
86+
87+
88+
def test_unavailable_mixin_reports_first(capsys):
89+
with pytest.raises(SystemExit):
90+
_parse(['build', '--mixin', 'bad', 'a'])
91+
assert "Mixin 'bad' is not available" in capsys.readouterr().err

0 commit comments

Comments
 (0)