From 5b2491860c6dc165cf268d44e68db63fda171e98 Mon Sep 17 00:00:00 2001 From: Thor Whalen <1906276+thorwhalen@users.noreply.github.com> Date: Tue, 22 Sep 2026 15:34:07 +0000 Subject: [PATCH] fix: DAG signatures treat i2's NotSet default as "no default" `DAG.src_name_params` now maps a func node param's `NotSet` default (i2's sentinel) to no default (`util.not_set_to_empty`) before merging and sorting. Without this, once i2mint/i2#88 re-lands, a DAG holding a FuncFactory node would silently reorder its positional params (NotSet params sorted after required ones: `dag(1, 2)` binding to the wrong roots), and merging with a same-named required param would raise. No-op for current signatures. Uses `i2.is_not_set` when available, else falls back on `i2.deco.NotSet`. Refs i2mint/i2#48 Co-Authored-By: Claude Opus 5 --- meshed/dag.py | 4 +- meshed/tests/test_not_set_defaults.py | 57 +++++++++++++++++++++++++++ meshed/util.py | 28 +++++++++++++ 3 files changed, 88 insertions(+), 1 deletion(-) create mode 100644 meshed/tests/test_not_set_defaults.py diff --git a/meshed/dag.py b/meshed/dag.py index 8c415fe8..d24d8a68 100644 --- a/meshed/dag.py +++ b/meshed/dag.py @@ -200,6 +200,7 @@ extract_items, ParameterMerger, conservative_parameter_merge, + not_set_to_empty, ) from meshed.itools import ( topological_sort, @@ -1254,8 +1255,9 @@ def src_name_params(self, src_names: Iterable[str] | None = None): for src_name in filter(src_names.__contains__, d): params = d[src_name] # consider all the params that use it # make version of these params that have the same name (namely src_name) + # (i2's NotSet sentinel default counts as "no default": see not_set_to_empty) params_with_name_changed_to_src_name = [ - p.replace(name=src_name) for p in params + not_set_to_empty(p).replace(name=src_name) for p in params ] if len(params_with_name_changed_to_src_name) == 1: # if there's only one param, yield it (there can be no conflict) diff --git a/meshed/tests/test_not_set_defaults.py b/meshed/tests/test_not_set_defaults.py new file mode 100644 index 00000000..07dc915f --- /dev/null +++ b/meshed/tests/test_not_set_defaults.py @@ -0,0 +1,57 @@ +"""``i2``'s ``NotSet`` sentinel in a signature means "required / no default". + +See i2mint/i2#48: once ``i2.FuncFactory`` shows ``NotSet`` defaults, a DAG holding +such a node must keep the signature it has today. Otherwise its positional order +changes silently (``NotSet``-defaulted params would be sorted after required ones), +and merging with a same-named, non-defaulted param would raise. +These tests use ``i2.deco.NotSet`` directly, so they pass with any i2 version. +""" + +import pytest +from i2 import Sig +from i2.deco import NotSet + +from meshed import DAG + + +def g(a): + return a + + +# ``g`` with a ``NotSet`` default, as a re-landed i2#88 ``FuncFactory`` would show. +def g_with_not_set(a=NotSet): + return a + + +g_with_not_set.__name__ = g.__name__ + + +def h(z): + return z * 10 + + +def k(a, z): + return a - z + + +def test_dag_signature_is_the_same_as_without_not_set(): + dag, plain = DAG([g_with_not_set, h]), DAG([g, h]) + assert str(Sig(dag)) == str(Sig(plain)) == "(a, z)" + assert dag(1, 2) == plain(1, 2) # positional order is kept + + +def test_not_set_param_merges_with_same_named_required_param(): + dag = DAG([g_with_not_set, k]) # used to raise: "didn't have the same default" + assert str(Sig(dag)) == str(Sig(DAG([g, k]))) + + +def test_not_set_root_is_required(): + with pytest.raises(TypeError): + DAG([g_with_not_set])() + + +def test_real_defaults_are_kept(): + def f(a=3): + return a + + assert DAG([f])() == 3 diff --git a/meshed/util.py b/meshed/util.py index 747a82be..7062033d 100644 --- a/meshed/util.py +++ b/meshed/util.py @@ -46,6 +46,34 @@ from i2 import Sig, name_of_obj, LiteralVal, FuncFanout, Pipe +try: + from i2 import is_not_set +except ImportError: # older i2: same sentinel, not exported from the root yet + from i2.deco import NotSet as _NotSet + + def is_not_set(x) -> bool: + """Return True iff ``x`` is ``i2``'s ``NotSet`` sentinel.""" + return x is _NotSet + + +def not_set_to_empty(param: Parameter) -> Parameter: + """Return ``param``, but with no default if its default is ``i2``'s ``NotSet``. + + ``NotSet`` in a signature (e.g. an ``i2.FuncFactory``'s) means "no value given", + not a real default. A DAG treats such a param as required, so that it keeps its + positional order and merges with same-named params that have no default. + + >>> from i2.deco import NotSet + >>> not_set_to_empty(Parameter('x', Parameter.KEYWORD_ONLY, default=NotSet)) + + >>> not_set_to_empty(Parameter('x', Parameter.KEYWORD_ONLY, default=3)) + + """ + if is_not_set(param.default): + return param.replace(default=Parameter.empty) + return param + + T = TypeVar("T")