Skip to content

Commit 7822bef

Browse files
ArizmendiWanclaude
andcommitted
Add mixin composition support (colcon-mixin#39)
Allow mixins to reference other mixins via a 'mixin' key containing a list of mixin names. Referenced mixins are resolved recursively with cycle detection, and their arguments are merged left-to-right before the composing mixin's own arguments are applied on top. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: ArizmendiWan <2311602492@qq.com>
1 parent ebedd27 commit 7822bef

3 files changed

Lines changed: 161 additions & 1 deletion

File tree

colcon_mixin/mixin/__init__.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,3 +125,60 @@ def add_mixins(mixin_path, mixins_by_verb):
125125
'with the same name' %
126126
(name, mixin_path.absolute()))
127127
mixins_by_verb[verb_key][name] = args
128+
129+
130+
def resolve_mixin(verb_key, mixin_name, mixins_by_verb, _resolving=None):
131+
"""
132+
Resolve a mixin by flattening its composed references.
133+
134+
Referenced mixins are resolved left-to-right, then the mixin's own
135+
arguments are merged on top. Precedence matches the CLI behavior of
136+
_update_args: lists use prepending order, first scalar wins between
137+
references, own scalars always override.
138+
139+
:param tuple verb_key: The verb tuple
140+
:param str mixin_name: The name of the mixin to resolve
141+
:param dict mixins_by_verb: The full mixin collection
142+
:param set _resolving: Internal set for cycle detection
143+
:raises RuntimeError: On cycles or missing references
144+
:rtype: dict
145+
"""
146+
if _resolving is None:
147+
_resolving = set()
148+
149+
verb_mixins = mixins_by_verb.get(verb_key, {})
150+
if mixin_name not in verb_mixins:
151+
raise RuntimeError(
152+
"Mixin '%s' referenced in composition does not exist for "
153+
"verb '%s'" % (mixin_name, '.'.join(verb_key)))
154+
155+
if mixin_name in _resolving:
156+
raise RuntimeError(
157+
'Cycle detected in mixin composition: %s' % mixin_name)
158+
159+
mixin_def = verb_mixins[mixin_name]
160+
refs = mixin_def.get('mixin', [])
161+
if not isinstance(refs, list):
162+
refs = []
163+
164+
_resolving.add(mixin_name)
165+
resolved = {}
166+
for ref in refs:
167+
ref_args = resolve_mixin(verb_key, ref, mixins_by_verb, _resolving)
168+
for k, v in ref_args.items():
169+
if k not in resolved:
170+
resolved[k] = v
171+
elif isinstance(resolved[k], list) and isinstance(v, list):
172+
resolved[k] = v + resolved[k]
173+
_resolving.discard(mixin_name)
174+
175+
for k, v in mixin_def.items():
176+
if k == 'mixin':
177+
continue
178+
if k not in resolved:
179+
resolved[k] = v
180+
elif isinstance(resolved[k], list) and isinstance(v, list):
181+
resolved[k] = v + resolved[k]
182+
else:
183+
resolved[k] = v
184+
return resolved

colcon_mixin/mixin/mixin_argument.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
from colcon_core.plugin_system import satisfies_version
2525
from colcon_mixin.mixin import add_mixins
2626
from colcon_mixin.mixin import get_mixins
27+
from colcon_mixin.mixin import resolve_mixin
2728

2829
logger = colcon_logger.getChild(__name__)
2930

@@ -187,7 +188,11 @@ def collect_parsers_by_verb(root, parsers, parent_verbs=()):
187188
self._parser.error(
188189
"Mixin '{mixin}' is not available for '{context}'"
189190
.format_map(locals()))
190-
mixin_args = mixins[mixin]
191+
try:
192+
mixin_args = resolve_mixin(
193+
args.mixin_verb, mixin, mixins_by_verb)
194+
except RuntimeError as e:
195+
self._parser.error(str(e))
191196
logger.debug(
192197
"Using mixin '{mixin}': {mixin_args}".format_map(locals()))
193198
self._update_args(args, mixin_args, '.'.join(args.mixin_verb))

test/test_mixin_composition.py

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
# Copyright 2026 Open Robotics
2+
# Licensed under the Apache License, Version 2.0
3+
4+
from collections import defaultdict
5+
6+
from colcon_mixin.mixin import resolve_mixin
7+
import pytest
8+
9+
10+
def _make_mixins(mixin_dict):
11+
"""Build a mixins_by_verb dict for verb ('build',)."""
12+
mbv = defaultdict(dict)
13+
mbv[('build',)] = dict(mixin_dict)
14+
return mbv
15+
16+
17+
def test_resolve_no_composition():
18+
"""Mixin without a 'mixin' key returns its args unchanged."""
19+
mbv = _make_mixins({
20+
'debug': {'args': ['--flag-a']},
21+
})
22+
result = resolve_mixin(('build',), 'debug', mbv)
23+
assert result == {'args': ['--flag-a']}
24+
25+
26+
def test_resolve_simple():
27+
"""Mixin referencing another gets the referenced args plus its own."""
28+
mbv = _make_mixins({
29+
'base': {'args': ['--flag-a']},
30+
'composed': {
31+
'mixin': ['base'],
32+
'build-base': '/tmp/build',
33+
},
34+
})
35+
result = resolve_mixin(('build',), 'composed', mbv)
36+
assert result == {
37+
'args': ['--flag-a'],
38+
'build-base': '/tmp/build',
39+
}
40+
41+
42+
def test_resolve_list_concatenation():
43+
"""List args use prepending order, matching CLI --mixin."""
44+
mbv = _make_mixins({
45+
'base': {'args': ['--flag-a']},
46+
'extra': {
47+
'mixin': ['base'],
48+
'args': ['--flag-b'],
49+
},
50+
})
51+
result = resolve_mixin(('build',), 'extra', mbv)
52+
# own args go first, matching _update_args behavior
53+
assert result == {
54+
'args': ['--flag-b', '--flag-a'],
55+
}
56+
57+
58+
def test_resolve_scalar_own_overrides_ref():
59+
"""Own scalar args override referenced mixin's scalar args."""
60+
mbv = _make_mixins({
61+
'base': {'build-base': '/tmp/base'},
62+
'override': {
63+
'mixin': ['base'],
64+
'build-base': '/tmp/override',
65+
},
66+
})
67+
result = resolve_mixin(('build',), 'override', mbv)
68+
assert result == {'build-base': '/tmp/override'}
69+
70+
71+
def test_resolve_scalar_first_ref_wins():
72+
"""Between references, first scalar wins (matches CLI behavior)."""
73+
mbv = _make_mixins({
74+
'a': {'build-base': '/from-a'},
75+
'b': {'build-base': '/from-b'},
76+
'composed': {'mixin': ['a', 'b']},
77+
})
78+
result = resolve_mixin(('build',), 'composed', mbv)
79+
assert result == {'build-base': '/from-a'}
80+
81+
82+
def test_resolve_cycle_detection():
83+
"""Cyclic references raise RuntimeError."""
84+
mbv = _make_mixins({
85+
'a': {'mixin': ['b'], 'x': 1},
86+
'b': {'mixin': ['a'], 'y': 2},
87+
})
88+
with pytest.raises(RuntimeError, match='Cycle detected'):
89+
resolve_mixin(('build',), 'a', mbv)
90+
91+
92+
def test_resolve_missing_reference():
93+
"""Reference to a non-existent mixin raises RuntimeError."""
94+
mbv = _make_mixins({
95+
'broken': {'mixin': ['nonexistent']},
96+
})
97+
with pytest.raises(RuntimeError, match='does not exist'):
98+
resolve_mixin(('build',), 'broken', mbv)

0 commit comments

Comments
 (0)