Skip to content

Commit 19cc725

Browse files
committed
Support mixins referencing other mixins via a reserved 'mixin' key
A mixin definition may now reference other mixins through a 'mixin' key whose value is a list of mixin names, so a high-level mixin can be composed from smaller building-block mixins. The application order is computed by a new standalone module (colcon_mixin/mixin/order.py) using a depth-first, post-order traversal with re-application: referenced mixins are recorded before the mixin that references them, and a mixin reachable through multiple paths is applied once per path to preserve last-applied-wins semantics. Cycle detection uses the active recursion path only, so legitimate re-application is never mistaken for a cycle. In parse_args the requested mixins are expanded into the full application order and applied in reverse, so the prepend-based overlay produces the computed order while explicit command line arguments keep precedence. The referencing mixin is applied conceptually last and can override values inherited from its references. The reserved 'mixin' key is skipped during overlay since it is metadata, not an argument. Circular references, unknown references and malformed 'mixin' keys are reported as clean CLI errors. Mixins without a 'mixin' key behave exactly as before. Add unit tests for the ordering algorithm and parser integration tests covering nested references, scalar overrides, the canonical reference graph, command line precedence and error reporting.
1 parent 6dbbae0 commit 19cc725

4 files changed

Lines changed: 344 additions & 5 deletions

File tree

colcon_mixin/mixin/mixin_argument.py

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,10 @@
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.order import CircularMixinError
28+
from colcon_mixin.mixin.order import compute_application_order
29+
from colcon_mixin.mixin.order import InvalidMixinError
30+
from colcon_mixin.mixin.order import MissingMixinError
2731

2832
logger = colcon_logger.getChild(__name__)
2933

@@ -181,23 +185,36 @@ def collect_parsers_by_verb(root, parsers, parent_verbs=()):
181185
# update args based on selected mixins
182186
if 'mixin_verb' in args:
183187
mixins = mixins_by_verb.get(args.mixin_verb, {})
188+
context = '.'.join(args.mixin_verb)
184189
# validate the requested mixins in the order given on the command
185190
# line so an error reports the first unavailable mixin
186191
for mixin in args.mixin or ():
187192
if mixin not in mixins:
188-
context = '.'.join(args.mixin_verb)
189193
self._parser.error(
190194
"Mixin '{mixin}' is not available for '{context}'"
191195
.format_map(locals()))
196+
# expand each requested mixin into the full list of mixins to
197+
# apply: a mixin may reference other mixins through a 'mixin' key,
198+
# which are applied first so the requesting mixin can override
199+
# them (depth-first post-order, see colcon_mixin.mixin.order)
200+
application_order = []
201+
for mixin in args.mixin or ():
202+
try:
203+
application_order += compute_application_order(
204+
mixins, mixin)
205+
except (
206+
CircularMixinError, InvalidMixinError, MissingMixinError,
207+
) as e:
208+
self._parser.error(str(e))
192209
# apply the mixins in reverse order: since _update_args() handles
193210
# 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-
for mixin in reversed(args.mixin or ()):
211+
# makes the resulting list follow the computed application order
212+
# while keeping any explicit command line arguments last
213+
for mixin in reversed(application_order):
197214
mixin_args = mixins[mixin]
198215
logger.debug(
199216
"Using mixin '{mixin}': {mixin_args}".format_map(locals()))
200-
self._update_args(args, mixin_args, '.'.join(args.mixin_verb))
217+
self._update_args(args, mixin_args, context)
201218

202219
# undo default value wrapping injected in the add_argument() method
203220
for k, v in args.__dict__.items():
@@ -261,6 +278,10 @@ def _update_mixin_argument(self, argument, mixins):
261278
def _update_args(self, args, mixin_args, context):
262279
destinations = self.get_destinations()
263280
for mixin_key, mixin_value in mixin_args.items():
281+
if mixin_key == 'mixin':
282+
# reserved metadata key listing referenced mixins; it is not
283+
# an argument to overlay (see colcon_mixin.mixin.order)
284+
continue
264285
if mixin_key not in destinations:
265286
logger.warning(
266287
"Mixin key '{mixin_key}' is not a valid argument for "

colcon_mixin/mixin/order.py

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
# Copyright 2026 C Innocent Pious
2+
# Licensed under the Apache License, Version 2.0
3+
4+
"""Compute the application order of mixins which reference other mixins.
5+
6+
A mixin definition may reference other mixins through a ``"mixin"`` key whose
7+
value is a list of other mixin names. Given a requested mixin and the mapping
8+
of all available mixins, :func:`compute_application_order` produces the ordered
9+
list of mixin names to apply using a depth-first, post-order traversal with
10+
re-application.
11+
12+
The traversal model:
13+
14+
* Depth-first -- follow each reference chain to its end before backing up.
15+
* Post-order -- record a mixin AFTER its referenced mixins, so dependencies
16+
come first and the referencing mixin comes last and can override them.
17+
* Re-application -- nodes are NOT skipped when reached through multiple
18+
paths; a shared mixin is applied once per path. This preserves correct
19+
last-applied-wins override behavior.
20+
21+
Cycle detection uses the active recursion path only (not a global visited
22+
set), so legitimate re-application is never mistaken for a cycle.
23+
24+
This module only yields the order in which mixins should be applied; merging
25+
their arguments is a separate concern handled by the caller.
26+
"""
27+
28+
29+
class CircularMixinError(Exception):
30+
"""Raised when the mixin reference graph contains a cycle.
31+
32+
The message names the full cycle path, e.g. 'A -> B -> A'.
33+
"""
34+
35+
36+
class MissingMixinError(Exception):
37+
"""Raised when a referenced mixin name does not exist in the mixins."""
38+
39+
40+
class InvalidMixinError(Exception):
41+
"""Raised when a 'mixin' key is malformed (not a list of strings)."""
42+
43+
44+
def _read_references(mixins, name):
45+
"""Read and validate the references of a single mixin.
46+
47+
:param dict mixins: mapping of mixin name -> mixin definition.
48+
:param str name: the mixin whose references should be read.
49+
:returns: the list of referenced mixin names (empty if no 'mixin' key).
50+
:rtype: list
51+
:raises InvalidMixinError: if the 'mixin' key is not a list of strings.
52+
"""
53+
refs = mixins[name].get('mixin', [])
54+
if not isinstance(refs, list) or not all(
55+
isinstance(ref, str) for ref in refs
56+
):
57+
raise InvalidMixinError(
58+
"Mixin '{name}' has an invalid 'mixin' key: expected a list of "
59+
'strings'.format_map(locals()))
60+
return refs
61+
62+
63+
def compute_application_order(mixins, requested):
64+
"""Compute the order in which mixins should be applied.
65+
66+
Performs a depth-first, post-order traversal of the mixin reference
67+
graph, re-applying nodes on each encounter (no skipping of
68+
already-visited nodes). Dependencies are ordered before the mixins that
69+
reference them, so the requesting mixin appears last and can override its
70+
dependencies.
71+
72+
:param dict mixins: mapping of mixin name -> mixin definition. A
73+
definition is a dict that MAY contain a "mixin" key whose value is a
74+
list of other mixin names it references. Definitions without a
75+
"mixin" key have no references.
76+
:param str requested: the name of the mixin the user requested.
77+
:returns: list of mixin names in application order (may contain
78+
duplicates).
79+
:rtype: list
80+
:raises CircularMixinError: if a circular reference is detected.
81+
:raises MissingMixinError: if a referenced mixin does not exist.
82+
:raises InvalidMixinError: if a "mixin" key is malformed.
83+
"""
84+
order = []
85+
# names currently on the active recursion path, used for cycle detection
86+
stack_path = []
87+
88+
def visit(name, referrer):
89+
if name not in mixins:
90+
if referrer is None:
91+
raise MissingMixinError(
92+
"Requested mixin '{name}' does not exist"
93+
.format_map(locals()))
94+
raise MissingMixinError(
95+
"Mixin '{referrer}' references unknown mixin '{name}'"
96+
.format_map(locals()))
97+
98+
if name in stack_path:
99+
path = ' -> '.join(stack_path + [name])
100+
raise CircularMixinError(
101+
'Circular mixin reference: {path}'.format_map(locals()))
102+
103+
refs = _read_references(mixins, name)
104+
105+
stack_path.append(name)
106+
for ref in refs: # left-to-right, user-listed order
107+
visit(ref, name) # depth-first
108+
stack_path.pop()
109+
110+
order.append(name) # POST-ORDER: record after references
111+
112+
visit(requested, None)
113+
return order

test/test_mixin_argument.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,28 @@
2020
}
2121

2222

23+
# a mixin referencing another mixin through the 'mixin' key
24+
NESTED_MIXINS = {
25+
'base': {'cmake-args': ['FOO=BASE']},
26+
'child': {'mixin': ['base'], 'cmake-args': ['FOO=CHILD']},
27+
}
28+
29+
30+
SCALAR_NESTED_MIXINS = {
31+
'base': {'build-base': 'BASE'},
32+
'child': {'mixin': ['base'], 'build-base': 'CHILD'},
33+
}
34+
35+
36+
# the canonical graph from the reference doc: A->[B,D], B->[C], C->[D]
37+
CANONICAL_MIXINS = {
38+
'A': {'mixin': ['B', 'D'], 'cmake-args': ['A']},
39+
'B': {'mixin': ['C'], 'cmake-args': ['B']},
40+
'C': {'mixin': ['D'], 'cmake-args': ['C']},
41+
'D': {'cmake-args': ['D']},
42+
}
43+
44+
2345
def _parse(argv, mixins=MIXINS):
2446
base = argparse.ArgumentParser(prog='colcon')
2547
decorator = MixinArgumentDecorator(base)
@@ -88,3 +110,45 @@ def test_unavailable_mixin_reports_first(capsys):
88110
with pytest.raises(SystemExit):
89111
_parse(['build', '--mixin', 'bad', 'a'])
90112
assert "Mixin 'bad' is not available" in capsys.readouterr().err
113+
114+
115+
def test_referenced_mixin_applied_before_referencing():
116+
# 'child' references 'base'; the referenced mixin is applied first so its
117+
# list values come before the referencing mixin's values
118+
args = _parse(['build', '--mixin', 'child'], NESTED_MIXINS)
119+
assert args.cmake_args == ['FOO=BASE', 'FOO=CHILD']
120+
121+
122+
def test_referencing_mixin_overrides_referenced_scalar():
123+
# a scalar from the referencing mixin must win over its reference
124+
args = _parse(['build', '--mixin', 'child'], SCALAR_NESTED_MIXINS)
125+
assert args.build_base == 'CHILD'
126+
127+
128+
def test_canonical_reference_graph_order():
129+
# the overlaid list follows the canonical [D, C, B, D, A] order
130+
args = _parse(['build', '--mixin', 'A'], CANONICAL_MIXINS)
131+
assert args.cmake_args == ['D', 'C', 'B', 'D', 'A']
132+
133+
134+
def test_nested_mixin_command_line_arguments_take_precedence():
135+
args = _parse(
136+
['build', '--mixin', 'child', '--cmake-args=FOO=CLI'], NESTED_MIXINS)
137+
assert args.cmake_args == ['FOO=BASE', 'FOO=CHILD', 'FOO=CLI']
138+
139+
140+
def test_circular_mixin_reference_reports_error(capsys):
141+
mixins = {
142+
'a': {'mixin': ['b']},
143+
'b': {'mixin': ['a']},
144+
}
145+
with pytest.raises(SystemExit):
146+
_parse(['build', '--mixin', 'a'], mixins)
147+
assert 'Circular mixin reference' in capsys.readouterr().err
148+
149+
150+
def test_unknown_referenced_mixin_reports_error(capsys):
151+
mixins = {'a': {'mixin': ['missing']}}
152+
with pytest.raises(SystemExit):
153+
_parse(['build', '--mixin', 'a'], mixins)
154+
assert "unknown mixin 'missing'" in capsys.readouterr().err

test/test_order.py

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
# Copyright 2026 C Innocent Pious
2+
# Licensed under the Apache License, Version 2.0
3+
4+
"""Tests for the mixin application-order function."""
5+
6+
from colcon_mixin.mixin.order import CircularMixinError
7+
from colcon_mixin.mixin.order import compute_application_order
8+
from colcon_mixin.mixin.order import InvalidMixinError
9+
from colcon_mixin.mixin.order import MissingMixinError
10+
import pytest
11+
12+
13+
def test_no_mixin_key():
14+
mixins = {'A': {'cmake-args': ['-DA=1']}}
15+
assert compute_application_order(mixins, 'A') == ['A']
16+
17+
18+
def test_empty_mixin_list():
19+
mixins = {'A': {'mixin': [], 'cmake-args': ['-DA=1']}}
20+
assert compute_application_order(mixins, 'A') == ['A']
21+
22+
23+
def test_single_reference():
24+
mixins = {
25+
'A': {'mixin': ['B']},
26+
'B': {},
27+
}
28+
assert compute_application_order(mixins, 'A') == ['B', 'A']
29+
30+
31+
def test_two_references_order_preserved():
32+
mixins = {
33+
'A': {'mixin': ['B', 'C']},
34+
'B': {},
35+
'C': {},
36+
}
37+
assert compute_application_order(mixins, 'A') == ['B', 'C', 'A']
38+
39+
40+
def test_nested_chain():
41+
mixins = {
42+
'A': {'mixin': ['B']},
43+
'B': {'mixin': ['C']},
44+
'C': {'mixin': ['D']},
45+
'D': {},
46+
}
47+
assert compute_application_order(mixins, 'A') == ['D', 'C', 'B', 'A']
48+
49+
50+
def test_canonical_example():
51+
mixins = {
52+
'A': {'mixin': ['B', 'D'], 'cmake-args': ['-DA=1']},
53+
'B': {'mixin': ['C'], 'cmake-args': ['-DB=1']},
54+
'C': {'mixin': ['D'], 'cmake-args': ['-DC=1']},
55+
'D': {'cmake-args': ['-DD=1']},
56+
}
57+
assert compute_application_order(mixins, 'A') == \
58+
['D', 'C', 'B', 'D', 'A']
59+
60+
61+
def test_diamond_re_application():
62+
# A references B and C; both reference D. D is shared but reached via two
63+
# distinct, already-completed branches -> it appears once per path.
64+
mixins = {
65+
'A': {'mixin': ['B', 'C']},
66+
'B': {'mixin': ['D']},
67+
'C': {'mixin': ['D']},
68+
'D': {},
69+
}
70+
order = compute_application_order(mixins, 'A')
71+
assert order == ['D', 'B', 'D', 'C', 'A']
72+
assert order.count('D') == 2
73+
74+
75+
def test_self_reference_cycle():
76+
mixins = {'A': {'mixin': ['A']}}
77+
with pytest.raises(CircularMixinError) as exc_info:
78+
compute_application_order(mixins, 'A')
79+
assert 'A -> A' in str(exc_info.value)
80+
81+
82+
def test_two_node_cycle():
83+
mixins = {
84+
'A': {'mixin': ['B']},
85+
'B': {'mixin': ['A']},
86+
}
87+
with pytest.raises(CircularMixinError) as exc_info:
88+
compute_application_order(mixins, 'A')
89+
assert 'A -> B -> A' in str(exc_info.value)
90+
91+
92+
def test_three_node_cycle():
93+
mixins = {
94+
'A': {'mixin': ['B']},
95+
'B': {'mixin': ['C']},
96+
'C': {'mixin': ['A']},
97+
}
98+
with pytest.raises(CircularMixinError) as exc_info:
99+
compute_application_order(mixins, 'A')
100+
assert 'A -> B -> C -> A' in str(exc_info.value)
101+
102+
103+
def test_missing_referenced_mixin():
104+
mixins = {'A': {'mixin': ['B']}}
105+
with pytest.raises(MissingMixinError) as exc_info:
106+
compute_application_order(mixins, 'A')
107+
assert "'A'" in str(exc_info.value)
108+
assert "'B'" in str(exc_info.value)
109+
110+
111+
def test_malformed_mixin_string_not_list():
112+
mixins = {'A': {'mixin': 'B'}}
113+
with pytest.raises(InvalidMixinError):
114+
compute_application_order(mixins, 'A')
115+
116+
117+
def test_malformed_mixin_list_with_non_string():
118+
mixins = {
119+
'A': {'mixin': ['B', 1]},
120+
'B': {},
121+
}
122+
with pytest.raises(InvalidMixinError):
123+
compute_application_order(mixins, 'A')
124+
125+
126+
def test_requested_mixin_not_in_dict():
127+
mixins = {'A': {}}
128+
with pytest.raises(MissingMixinError):
129+
compute_application_order(mixins, 'Z')
130+
131+
132+
def test_mixin_key_never_in_output():
133+
mixins = {
134+
'A': {'mixin': ['B', 'D']},
135+
'B': {'mixin': ['C']},
136+
'C': {'mixin': ['D']},
137+
'D': {},
138+
}
139+
order = compute_application_order(mixins, 'A')
140+
assert 'mixin' not in order
141+
assert set(order) <= set(mixins.keys())

0 commit comments

Comments
 (0)