From 235fb2bebf7699a40f62dd284d8c02e88956a1a7 Mon Sep 17 00:00:00 2001 From: Julian Kimmig Date: Tue, 17 Mar 2026 09:48:44 +0100 Subject: [PATCH 1/2] fix(io): preserve trigger propagation semantics across node chains Pass through explicit does_trigger values for forwarded inputs while keeping output propagation dependent on downstream input flags unless suppression is explicit. Add matrix coverage for multi-node forwarding, fan-out, and mixed propagation scenarios to lock in the expected trigger behavior. --- src/funcnodes_core/io.py | 16 +- tests/test_nodeclass.py | 174 +++++++++++ tests/test_trigger_propagation_matrix.py | 353 +++++++++++++++++++++++ 3 files changed, 538 insertions(+), 5 deletions(-) create mode 100644 tests/test_trigger_propagation_matrix.py diff --git a/src/funcnodes_core/io.py b/src/funcnodes_core/io.py index f9f27ec..aed2825 100644 --- a/src/funcnodes_core/io.py +++ b/src/funcnodes_core/io.py @@ -1039,10 +1039,11 @@ def set_value( self.datapath = new_datapath + resolved_does_trigger = does_trigger if self.node is not None: - if does_trigger is None: - does_trigger = self.does_trigger - if does_trigger: + if resolved_does_trigger is None: + resolved_does_trigger = self.does_trigger + if resolved_does_trigger: self.node.request_trigger() for other in self._forwards: @@ -1153,7 +1154,7 @@ def forward(self, other: NodeInput, replace=False): self._forwards.add(other) other.forwards_from(self, replace=replace) - other.set_value(self.value) + other.set_value(self.value, does_trigger=False) return [ self.node.uuid if self.node else None, @@ -1304,6 +1305,7 @@ def set_value( else: datapath = None # input_paths = [] + propagated_does_trigger = False if does_trigger is False else None for other in self.connections: # if self.node is not None: # for input_path in input_paths: @@ -1311,7 +1313,11 @@ def set_value( # else: # datapath = None - other.set_value(value, does_trigger=does_trigger, datapath=datapath) + other.set_value( + value, + does_trigger=propagated_does_trigger, + datapath=datapath, + ) def post_connect(self, other: NodeIO): """Called after a connection is made. diff --git a/tests/test_nodeclass.py b/tests/test_nodeclass.py index a3bd6e6..90d6773 100644 --- a/tests/test_nodeclass.py +++ b/tests/test_nodeclass.py @@ -37,6 +37,25 @@ async def func(self, input: int) -> int: # noqa: A003 - matching fn signature return input +def make_counter_node(node_id: str, *, does_trigger: bool = True): + resolved_node_id = node_id + + class CounterNode(Node): + node_id = resolved_node_id + value = NodeInput(id="value", type=int, does_trigger=does_trigger) + output = NodeOutput(id="output", type=int) + + def __init__(self, *args, **kwargs): + super().__init__(*args, pretrigger_delay=0.0, **kwargs) + self.call_count = 0 + + async def func(self, value: int): + self.call_count += 1 + self.outputs["output"].value = value + + return CounterNode + + @funcnodes_test async def test_nodeclass_initialization(): with pytest.raises(TypeError): @@ -128,6 +147,161 @@ async def test_trigger_stack(): assert not trigger_stack.done() +@funcnodes_test +async def test_forwarded_input_respects_target_does_trigger_flag(): + ForwardSourceNode = make_counter_node("forward_source_node_test") + ForwardTargetNode = make_counter_node( + "forward_target_node_test", does_trigger=False + ) + + node_a = ForwardSourceNode() + node_b = ForwardTargetNode() + node_a.inputs["value"].connect(node_b.inputs["value"]) + + node_a.inputs["value"].value = 42 + await fn.run_until_complete(node_a, node_b) + + assert node_a.inputs["value"].value == 42 + assert node_b.inputs["value"].value == 42 + assert node_a.call_count == 1 + assert node_b.call_count == 0 + assert node_a.outputs["output"].value == 42 + assert node_b.outputs["output"].value is fn.NoValue + + +@funcnodes_test +async def test_forwarded_input_uses_target_flag_when_source_flag_disables_self_only(): + ForwardSourceNode = make_counter_node( + "forward_source_node_no_trigger_test", does_trigger=False + ) + ForwardTargetNode = make_counter_node("forward_target_node_allow_test") + + node_a = ForwardSourceNode() + node_b = ForwardTargetNode() + node_a.inputs["value"].connect(node_b.inputs["value"]) + + node_a.inputs["value"].value = 42 + await fn.run_until_complete(node_a, node_b) + + assert node_a.call_count == 0 + assert node_b.call_count == 1 + assert node_b.inputs["value"].value == 42 + assert node_b.outputs["output"].value == 42 + + +@funcnodes_test +async def test_forwarded_input_propagates_explicit_false_downstream(): + ForwardSourceNode = make_counter_node("forward_source_node_explicit_false_test") + ForwardTargetNode = make_counter_node("forward_target_node_explicit_false_test") + + node_a = ForwardSourceNode() + node_b = ForwardTargetNode() + node_a.inputs["value"].connect(node_b.inputs["value"]) + + node_a.inputs["value"].set_value(42, does_trigger=False) + await fn.run_until_complete(node_a, node_b) + + assert node_a.call_count == 0 + assert node_b.call_count == 0 + assert node_b.inputs["value"].value == 42 + assert node_b.outputs["output"].value is fn.NoValue + + +@funcnodes_test +async def test_forwarded_input_triggers_downstream_when_both_allow_triggering(): + ForwardSourceNode = make_counter_node("forward_source_node_trigger_test") + ForwardTargetNode = make_counter_node("forward_target_node_trigger_test") + + node_a = ForwardSourceNode() + node_b = ForwardTargetNode() + node_a.inputs["value"].connect(node_b.inputs["value"]) + + node_a.inputs["value"].value = 42 + await fn.run_until_complete(node_a, node_b) + + assert node_a.call_count == 1 + assert node_b.call_count == 1 + assert node_b.outputs["output"].value == 42 + + +@funcnodes_test +async def test_forward_chain_skips_non_triggering_middle_node_but_triggers_downstream(): + NodeA = make_counter_node("forward_chain_source_node_test") + NodeB = make_counter_node("forward_chain_middle_node_test", does_trigger=False) + NodeC = make_counter_node("forward_chain_target_node_test") + + node_a = NodeA() + node_b = NodeB() + node_c = NodeC() + + node_a.inputs["value"].connect(node_b.inputs["value"]) + node_b.inputs["value"].connect(node_c.inputs["value"]) + + node_a.inputs["value"].value = 42 + await fn.run_until_complete(node_a, node_b, node_c) + + assert node_a.call_count == 1 + assert node_b.call_count == 0 + assert node_c.call_count == 1 + assert node_b.inputs["value"].value == 42 + assert node_c.inputs["value"].value == 42 + assert node_b.outputs["output"].value is fn.NoValue + assert node_c.outputs["output"].value == 42 + + +@funcnodes_test +async def test_output_set_value_respects_explicit_false_downstream(): + SourceNode = make_counter_node("output_source_node_explicit_false_test") + TargetNode = make_counter_node("output_target_node_explicit_false_test") + + node_a = SourceNode() + node_b = TargetNode() + node_a.outputs["output"].connect(node_b.inputs["value"]) + + node_a.outputs["output"].set_value(42, does_trigger=False) + await fn.run_until_complete(node_a, node_b) + + assert node_b.inputs["value"].value == 42 + assert node_b.call_count == 0 + assert node_b.outputs["output"].value is fn.NoValue + + +@funcnodes_test +async def test_output_set_value_uses_target_does_trigger_flag_when_unspecified(): + SourceNode = make_counter_node("output_source_node_unspecified_test") + TargetNode = make_counter_node( + "output_target_node_unspecified_false_test", does_trigger=False + ) + + node_a = SourceNode() + node_b = TargetNode() + node_a.outputs["output"].connect(node_b.inputs["value"]) + + node_a.outputs["output"].set_value(42) + await fn.run_until_complete(node_a, node_b) + + assert node_b.inputs["value"].value == 42 + assert node_b.call_count == 0 + assert node_b.outputs["output"].value is fn.NoValue + + +@funcnodes_test +async def test_output_set_value_triggers_target_when_target_allows_triggering(): + SourceNode = make_counter_node("output_source_node_trigger_test") + TargetNode = make_counter_node("output_target_node_trigger_test") + + node_a = SourceNode() + node_b = TargetNode() + node_a.outputs["output"].connect(node_b.inputs["value"]) + + node_a.outputs["output"].set_value(42) + await fn.run_until_complete(node_a, node_b) + + assert node_b.inputs["value"].value == 42 + assert node_b.call_count == 1 + assert node_b.outputs["output"].value == 42 + + @funcnodes_test def test_nodeclass_string(): test_node = DummyNode(uuid="test_uuid") diff --git a/tests/test_trigger_propagation_matrix.py b/tests/test_trigger_propagation_matrix.py new file mode 100644 index 0000000..a700987 --- /dev/null +++ b/tests/test_trigger_propagation_matrix.py @@ -0,0 +1,353 @@ +import pytest + +import funcnodes_core as fn +from funcnodes_core.node import Node, NodeInput, NodeOutput +from pytest_funcnodes import funcnodes_test + + +UNSPECIFIED = object() + + +def make_counter_node(node_id: str, *, does_trigger: bool = True): + resolved_node_id = node_id + + class CounterNode(Node): + node_id = resolved_node_id + value = NodeInput(id="value", type=int, does_trigger=does_trigger) + output = NodeOutput(id="output", type=int) + + def __init__(self, *args, **kwargs): + super().__init__(*args, pretrigger_delay=0.0, **kwargs) + self.call_count = 0 + + async def func(self, value: int): + self.call_count += 1 + self.outputs["output"].value = value + + return CounterNode + + +def build_nodes(scenario_name: str, node_flags: dict[str, bool]) -> dict[str, Node]: + nodes: dict[str, Node] = {} + for name, does_trigger in node_flags.items(): + node_cls = make_counter_node( + f"trigger_matrix_{scenario_name.lower()}_{name.lower()}", + does_trigger=does_trigger, + ) + nodes[name] = node_cls() + return nodes + + +def connect_nodes( + nodes: dict[str, Node], connections: list[tuple[str, str, str]] +) -> None: + for connection_type, src, dst in connections: + if connection_type == "forward": + nodes[src].inputs["value"].connect(nodes[dst].inputs["value"]) + elif connection_type == "output": + nodes[src].outputs["output"].connect(nodes[dst].inputs["value"]) + else: + raise ValueError(f"Unknown connection type: {connection_type}") + + +def apply_action(nodes: dict[str, Node], action: dict[str, object]) -> None: + node = nodes[action["node"]] # type: ignore[index] + value = action["value"] + does_trigger = action.get("does_trigger", UNSPECIFIED) + + if action["io"] == "input": + setter = node.inputs["value"].set_value + elif action["io"] == "output": + setter = node.outputs["output"].set_value + else: + raise ValueError(f"Unknown io type: {action['io']}") + + if does_trigger is UNSPECIFIED: + setter(value) + else: + setter(value, does_trigger=does_trigger) # type: ignore[arg-type] + + +async def run_scenario(scenario: dict[str, object]) -> None: + nodes = build_nodes(scenario["name"], scenario["node_flags"]) # type: ignore[arg-type] + connect_nodes(nodes, scenario["connections"]) # type: ignore[arg-type] + apply_action(nodes, scenario["action"]) # type: ignore[arg-type] + await fn.run_until_complete(*nodes.values()) + + expected_counts = scenario["expected_counts"] # type: ignore[assignment] + for name, expected_count in expected_counts.items(): + assert nodes[name].call_count == expected_count + + +INPUT_FORWARDING_SCENARIOS = [ + { + "name": "input_forward_tt_unspecified", + "node_flags": {"A": True, "B": True}, + "connections": [("forward", "A", "B")], + "action": {"node": "A", "io": "input", "value": 1}, + "expected_counts": {"A": 1, "B": 1}, + }, + { + "name": "input_forward_ft_unspecified", + "node_flags": {"A": False, "B": True}, + "connections": [("forward", "A", "B")], + "action": {"node": "A", "io": "input", "value": 1}, + "expected_counts": {"A": 0, "B": 1}, + }, + { + "name": "input_forward_tf_unspecified", + "node_flags": {"A": True, "B": False}, + "connections": [("forward", "A", "B")], + "action": {"node": "A", "io": "input", "value": 1}, + "expected_counts": {"A": 1, "B": 0}, + }, + { + "name": "input_forward_ff_unspecified", + "node_flags": {"A": False, "B": False}, + "connections": [("forward", "A", "B")], + "action": {"node": "A", "io": "input", "value": 1}, + "expected_counts": {"A": 0, "B": 0}, + }, + { + "name": "input_forward_tt_explicit_false", + "node_flags": {"A": True, "B": True}, + "connections": [("forward", "A", "B")], + "action": {"node": "A", "io": "input", "value": 1, "does_trigger": False}, + "expected_counts": {"A": 0, "B": 0}, + }, + { + "name": "input_forward_chain_tft_unspecified", + "node_flags": {"A": True, "B": False, "C": True}, + "connections": [("forward", "A", "B"), ("forward", "B", "C")], + "action": {"node": "A", "io": "input", "value": 1}, + "expected_counts": {"A": 1, "B": 0, "C": 1}, + }, + { + "name": "input_forward_chain_tft_explicit_false", + "node_flags": {"A": True, "B": False, "C": True}, + "connections": [("forward", "A", "B"), ("forward", "B", "C")], + "action": {"node": "A", "io": "input", "value": 1, "does_trigger": False}, + "expected_counts": {"A": 0, "B": 0, "C": 0}, + }, + { + "name": "input_forward_chain_ftft_unspecified", + "node_flags": {"A": False, "B": True, "C": False, "D": True}, + "connections": [ + ("forward", "A", "B"), + ("forward", "B", "C"), + ("forward", "C", "D"), + ], + "action": {"node": "A", "io": "input", "value": 1}, + "expected_counts": {"A": 0, "B": 1, "C": 0, "D": 1}, + }, + { + "name": "input_forward_fanout_t_tf_unspecified", + "node_flags": {"A": True, "B": True, "C": False}, + "connections": [("forward", "A", "B"), ("forward", "A", "C")], + "action": {"node": "A", "io": "input", "value": 1}, + "expected_counts": {"A": 1, "B": 1, "C": 0}, + }, + { + "name": "input_forward_fanout_t_tt_explicit_false", + "node_flags": {"A": True, "B": True, "C": True}, + "connections": [("forward", "A", "B"), ("forward", "A", "C")], + "action": {"node": "A", "io": "input", "value": 1, "does_trigger": False}, + "expected_counts": {"A": 0, "B": 0, "C": 0}, + }, + { + "name": "input_forward_tt_explicit_true", + "node_flags": {"A": True, "B": True}, + "connections": [("forward", "A", "B")], + "action": {"node": "A", "io": "input", "value": 1, "does_trigger": True}, + "expected_counts": {"A": 1, "B": 1}, + }, + { + "name": "input_forward_tf_explicit_true", + "node_flags": {"A": True, "B": False}, + "connections": [("forward", "A", "B")], + "action": {"node": "A", "io": "input", "value": 1, "does_trigger": True}, + "expected_counts": {"A": 1, "B": 1}, + }, + { + "name": "input_forward_ft_explicit_true", + "node_flags": {"A": False, "B": True}, + "connections": [("forward", "A", "B")], + "action": {"node": "A", "io": "input", "value": 1, "does_trigger": True}, + "expected_counts": {"A": 1, "B": 1}, + }, + { + "name": "input_forward_ff_explicit_true", + "node_flags": {"A": False, "B": False}, + "connections": [("forward", "A", "B")], + "action": {"node": "A", "io": "input", "value": 1, "does_trigger": True}, + "expected_counts": {"A": 1, "B": 1}, + }, + { + "name": "input_forward_chain_tft_explicit_true", + "node_flags": {"A": True, "B": False, "C": True}, + "connections": [("forward", "A", "B"), ("forward", "B", "C")], + "action": {"node": "A", "io": "input", "value": 1, "does_trigger": True}, + "expected_counts": {"A": 1, "B": 1, "C": 1}, + }, + { + "name": "input_forward_chain_tff_explicit_true", + "node_flags": {"A": True, "B": False, "C": False}, + "connections": [("forward", "A", "B"), ("forward", "B", "C")], + "action": {"node": "A", "io": "input", "value": 1, "does_trigger": True}, + "expected_counts": {"A": 1, "B": 1, "C": 1}, + }, + { + "name": "input_forward_fanout_t_ft_explicit_true", + "node_flags": {"A": True, "B": False, "C": True}, + "connections": [("forward", "A", "B"), ("forward", "A", "C")], + "action": {"node": "A", "io": "input", "value": 1, "does_trigger": True}, + "expected_counts": {"A": 1, "B": 1, "C": 1}, + }, + { + "name": "input_forward_diamond", + "node_flags": {"A": True, "B": True, "C": True, "D": True, "E": False}, + "connections": [ + ("forward", "A", "B"), + ("forward", "A", "C"), + ("forward", "B", "D"), + ("forward", "C", "E"), + ], + "action": {"node": "A", "io": "input", "value": 1}, + "expected_counts": {"A": 1, "B": 1, "C": 1, "D": 1, "E": 0}, + }, +] + + +OUTPUT_PROPAGATION_SCENARIOS = [ + { + "name": "output_to_input_t_unspecified", + "node_flags": {"A": True, "B": True}, + "connections": [("output", "A", "B")], + "action": {"node": "A", "io": "output", "value": 1}, + "expected_counts": {"A": 0, "B": 1}, + }, + { + "name": "output_to_input_f_unspecified", + "node_flags": {"A": True, "B": False}, + "connections": [("output", "A", "B")], + "action": {"node": "A", "io": "output", "value": 1}, + "expected_counts": {"A": 0, "B": 0}, + }, + { + "name": "output_to_input_t_explicit_false", + "node_flags": {"A": True, "B": True}, + "connections": [("output", "A", "B")], + "action": {"node": "A", "io": "output", "value": 1, "does_trigger": False}, + "expected_counts": {"A": 0, "B": 0}, + }, + { + "name": "output_forward_chain_ft_unspecified", + "node_flags": {"A": True, "B": False, "C": True}, + "connections": [("output", "A", "B"), ("forward", "B", "C")], + "action": {"node": "A", "io": "output", "value": 1}, + "expected_counts": {"A": 0, "B": 0, "C": 1}, + }, + { + "name": "output_forward_chain_ft_explicit_false", + "node_flags": {"A": True, "B": False, "C": True}, + "connections": [("output", "A", "B"), ("forward", "B", "C")], + "action": {"node": "A", "io": "output", "value": 1, "does_trigger": False}, + "expected_counts": {"A": 0, "B": 0, "C": 0}, + }, + { + "name": "output_to_input_t_explicit_true", + "node_flags": {"A": True, "B": True}, + "connections": [("output", "A", "B")], + "action": {"node": "A", "io": "output", "value": 1, "does_trigger": True}, + "expected_counts": {"A": 0, "B": 1}, + }, + { + "name": "output_to_input_f_explicit_true", + "node_flags": {"A": True, "B": False}, + "connections": [("output", "A", "B")], + "action": {"node": "A", "io": "output", "value": 1, "does_trigger": True}, + "expected_counts": {"A": 0, "B": 0}, + }, + { + "name": "output_forward_chain_ft_explicit_true", + "node_flags": {"A": True, "B": False, "C": True}, + "connections": [("output", "A", "B"), ("forward", "B", "C")], + "action": {"node": "A", "io": "output", "value": 1, "does_trigger": True}, + "expected_counts": {"A": 0, "B": 0, "C": 1}, + }, + { + "name": "output_forward_chain_tf_explicit_true", + "node_flags": {"A": True, "B": True, "C": False}, + "connections": [("output", "A", "B"), ("forward", "B", "C")], + "action": {"node": "A", "io": "output", "value": 1, "does_trigger": True}, + "expected_counts": {"A": 0, "B": 1, "C": 0}, + }, + { + "name": "output_forward_chain_ff_explicit_true", + "node_flags": {"A": True, "B": False, "C": False}, + "connections": [("output", "A", "B"), ("forward", "B", "C")], + "action": {"node": "A", "io": "output", "value": 1, "does_trigger": True}, + "expected_counts": {"A": 0, "B": 0, "C": 0}, + }, + { + "name": "output_fanout_explicit_true", + "node_flags": {"A": True, "B": False, "C": True}, + "connections": [("output", "A", "B"), ("output", "A", "C")], + "action": {"node": "A", "io": "output", "value": 1, "does_trigger": True}, + "expected_counts": {"A": 0, "B": 0, "C": 1}, + }, +] + + +MIXED_PROPAGATION_SCENARIOS = [ + { + "name": "execution_chain_output_output", + "node_flags": {"A": True, "B": True, "C": True}, + "connections": [("output", "A", "B"), ("output", "B", "C")], + "action": {"node": "A", "io": "output", "value": 1}, + "expected_counts": {"A": 0, "B": 1, "C": 1}, + }, + { + "name": "input_then_execution_chain_output_false", + "node_flags": {"A": True, "B": True, "C": False}, + "connections": [("forward", "A", "B"), ("output", "B", "C")], + "action": {"node": "A", "io": "input", "value": 1}, + "expected_counts": {"A": 1, "B": 1, "C": 0}, + }, + { + "name": "mixed_output_diamond", + "node_flags": {"A": True, "B": False, "C": True, "D": True, "E": True}, + "connections": [ + ("output", "A", "B"), + ("output", "A", "C"), + ("forward", "B", "D"), + ("output", "C", "E"), + ], + "action": {"node": "A", "io": "output", "value": 1}, + "expected_counts": {"A": 0, "B": 0, "C": 1, "D": 1, "E": 1}, + }, +] + + +@pytest.mark.parametrize( + "scenario", INPUT_FORWARDING_SCENARIOS, ids=lambda s: s["name"] +) +@funcnodes_test +async def test_input_forwarding_trigger_matrix(scenario): + await run_scenario(scenario) + + +@pytest.mark.parametrize( + "scenario", OUTPUT_PROPAGATION_SCENARIOS, ids=lambda s: s["name"] +) +@funcnodes_test +async def test_output_propagation_trigger_matrix(scenario): + await run_scenario(scenario) + + +@pytest.mark.parametrize( + "scenario", MIXED_PROPAGATION_SCENARIOS, ids=lambda s: s["name"] +) +@funcnodes_test +async def test_mixed_trigger_propagation_matrix(scenario): + await run_scenario(scenario) From a4bd6460adb19fa2974e270b662fb25252b8e02b Mon Sep 17 00:00:00 2001 From: Julian Kimmig Date: Tue, 17 Mar 2026 09:49:44 +0100 Subject: [PATCH 2/2] =?UTF-8?q?bump:=20version=202.3.2=20=E2=86=92=202.4.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 11 +++++++++++ pyproject.toml | 2 +- uv.lock | 2 +- 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d56108e..c4e93f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,14 @@ +## v2.4.0 (2026-03-17) + +### Feat + +- **context**: add context7 configuration file with URL and public key + +### Fix + +- **io**: preserve trigger propagation semantics across node chains +- **tests**: correct typo in test function name for clarity + ## v2.3.2 (2025-12-24) ### Fix diff --git a/pyproject.toml b/pyproject.toml index f991574..0e4bbbb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "funcnodes-core" -version = "2.3.2" +version = "2.4.0" description = "core package for funcnodes" authors = [{name = "Julian Kimmig", email = "julian.kimmig@linkdlab.de"}] diff --git a/uv.lock b/uv.lock index 1014ded..a7295b9 100644 --- a/uv.lock +++ b/uv.lock @@ -457,7 +457,7 @@ wheels = [ [[package]] name = "funcnodes-core" -version = "2.3.2" +version = "2.4.0" source = { editable = "." } dependencies = [ { name = "dill" },