From d1647325c57ad249ca00b35fde3081dfbf3b8fbc Mon Sep 17 00:00:00 2001 From: Thor Whalen <1906276+thorwhalen@users.noreply.github.com> Date: Tue, 22 Sep 2026 15:19:42 +0000 Subject: [PATCH 1/2] fix: treat i2's NotSet signature default as "required / no default" Input elements, input specs, pydantic model specs and argument annotation now read defaults through `front.util.param_default`, which maps i2's `NotSet` sentinel to `Parameter.empty`. So a signature with NotSet defaults (as a re-landed i2mint/i2#88 FuncFactory would show) no longer crashes numeric inputs (`int(NotSet)`), prefills text inputs with "NotSet", or becomes a pydantic default. No-op for current signatures. Uses `i2.is_not_set` when available, else falls back on `i2.deco.NotSet`, so the i2 floor is unchanged. Refs i2mint/i2#48 Co-Authored-By: Claude Opus 5 --- front/elements/elements.py | 9 ++-- front/py2pydantic.py | 11 ++-- front/util.py | 34 +++++++++++- tests/test_not_set_defaults.py | 98 ++++++++++++++++++++++++++++++++++ 4 files changed, 143 insertions(+), 9 deletions(-) create mode 100644 tests/test_not_set_defaults.py diff --git a/front/elements/elements.py b/front/elements/elements.py index 586a8340..5284d09c 100644 --- a/front/elements/elements.py +++ b/front/elements/elements.py @@ -24,7 +24,7 @@ from i2 import Sig from inspect import _empty from front.types import FrontElementDisplay, FrontElementName -from front.util import deep_merge, get_value +from front.util import deep_merge, get_value, param_default from i2.signatures import call_forgivingly # from pydantic import validate_arguments @@ -146,9 +146,10 @@ def mk_input_element_specs(obj, inputs): def mk_input_spec(p): input_spec = inputs_spec.get(p.name, {}) annot = p.annotation if p.annotation != _empty else None - param_type = annot or (type(p.default) if p.default != _empty else Any) + default = param_default(p) + param_type = annot or (type(default) if default != _empty else Any) param_origin_type = get_origin(param_type) - is_noneable = p.default is None + is_noneable = default is None if param_origin_type == Union: types = list(get_args(param_type)) none_type = type(None) @@ -272,7 +273,7 @@ def __post_init__(self): self.value = self._create_bound_data(self.input_key) if self.value.get() is ValueNotSet and value is not ValueNotSet: self.value.set(value) - dflt_value = self.obj.default + dflt_value = param_default(self.obj) if self.value.get() is ValueNotSet and dflt_value != _empty: self.value.set(dflt_value) self._init_view_value() diff --git a/front/py2pydantic.py b/front/py2pydantic.py index 8539e8bd..ef37e0b8 100644 --- a/front/py2pydantic.py +++ b/front/py2pydantic.py @@ -39,6 +39,8 @@ from i2 import Sig, name_of_obj, empty_param_attr from i2.wrapper import wrap, Ingress +from front.util import param_default + def pyd_func_ingress_template(input_model_instance, wrapped_func_sig: Sig): """Turn a pydantic model instance into the ``(args, kwargs)`` of ``wrapped_func_sig``. @@ -141,15 +143,16 @@ def func_to_pyd_model_specs(func: Callable, dflt_type=Any): ``dflt_type`` (``Any`` by default) is used with ``...`` (required). """ for p in Sig(func).params: + default = param_default(p) # i2's NotSet sentinel counts as "no default" if p.annotation is not empty_param_attr: - if p.default is not empty_param_attr: - yield p.name, (p.annotation, p.default) + if default is not empty_param_attr: + yield p.name, (p.annotation, default) else: yield p.name, (p.annotation, ...) else: # no annotations - if p.default is not empty_param_attr: + if default is not empty_param_attr: # pydantic v2 needs an explicit type; infer from the default - yield p.name, (type(p.default), p.default) + yield p.name, (type(default), default) else: yield p.name, (dflt_type, ...) diff --git a/front/util.py b/front/util.py index 8e0eaa93..a789e32a 100644 --- a/front/util.py +++ b/front/util.py @@ -15,6 +15,7 @@ from collections.abc import Iterable, Callable, Mapping from contextlib import suppress from enum import Enum +from inspect import Parameter from i2 import Sig, double_up_as_factory from i2.wrapper import Ingress, wrap @@ -22,6 +23,37 @@ from front.types import Map +try: + from i2 import is_not_set +except ImportError: # older i2: same sentinel, not exported from the root yet + + def is_not_set(x) -> bool: + """Return True iff ``x`` is ``i2``'s ``NotSet`` sentinel.""" + from i2.deco import NotSet + + return x is NotSet + + +def param_default(param): + """Return ``param.default``, or ``Parameter.empty`` if it is ``i2``'s ``NotSet``. + + ``NotSet`` in a signature means "no value given", not a real default, so UI and + schema builders must treat that param as required (no prefill, no type inference + from the default). + + >>> from inspect import Parameter + >>> from i2.deco import NotSet + >>> param_default(Parameter('x', Parameter.KEYWORD_ONLY, default=3)) + 3 + >>> param_default(Parameter('x', Parameter.KEYWORD_ONLY, default=NotSet)) + + >>> param_default(Parameter('x', Parameter.KEYWORD_ONLY)) + + """ + default = param.default + return Parameter.empty if is_not_set(default) else default + + ignore_import_problems = suppress(ImportError, ModuleNotFoundError) @@ -272,7 +304,7 @@ def _annotate_func_arguments( if ignore_existing_annot or param.annotation is empty: if name in annot_for_argname: yield name, {"annotation": annot_for_argname[name]} - elif isinstance(default := param.default, handled_types): + elif isinstance(default := param_default(param), handled_types): # NOTE: will yield the first one found for type_ in handled_types: if isinstance(default, type_): diff --git a/tests/test_not_set_defaults.py b/tests/test_not_set_defaults.py new file mode 100644 index 00000000..e5a26375 --- /dev/null +++ b/tests/test_not_set_defaults.py @@ -0,0 +1,98 @@ +"""``i2``'s ``NotSet`` sentinel in a signature means "required / no default". + +See i2mint/i2#48: once ``FuncFactory`` shows ``NotSet`` defaults, front must not +prefill inputs with it, infer an input type from it, or use it as a model default. +These tests use ``i2.deco.NotSet`` directly, so they pass with any i2 version. +""" + +from dataclasses import dataclass +from inspect import Parameter + +import pytest +from i2 import Sig +from i2.deco import NotSet + +from front.elements.elements import ( + FloatInputBase, + IntInputBase, + TextInputBase, + mk_input_element_specs, +) +from front.data_binding import BoundData +from front.py2pydantic import func_to_pyd_model_specs +from front.util import param_default + + +def _foo(a: int, b: float, c: str, d, e: int = 3): + return a, b, c, d, e + + +def _foo_with_not_set(): + """``_foo``'s signature with ``NotSet`` defaults, as a re-landed #88 would show it.""" + sig = Sig(_foo) + return sig.ch_defaults( + **{ + name: NotSet + for name in sig.names + if sig.parameters[name].default is Parameter.empty + } + )(_foo) + + +def _render_input(cls, param): + @dataclass + class Concrete(cls): + def render(self): + return self.view_value + + state = {} + element = Concrete( + obj=param, + input_key=f"k_{param.name}", + bound_data_factory=lambda k: BoundData(k, state), + ) + return element, state + + +def test_param_default_maps_not_set_to_empty(): + func = _foo_with_not_set() + params = Sig(func).parameters + assert params["a"].default is NotSet # the fixture really has NotSet defaults + assert [param_default(p) for p in params.values()] == [Parameter.empty] * 4 + [3] + + +@pytest.mark.parametrize( + "cls, name, expected_view", + [(IntInputBase, "a", 0), (FloatInputBase, "b", 0.0), (TextInputBase, "c", "")], +) +def test_inputs_are_not_prefilled_with_not_set(cls, name, expected_view): + param = Sig(_foo_with_not_set()).parameters[name] + element, state = _render_input(cls, param) # used to raise on int(NotSet) + assert element.view_value == expected_view + assert element.value.get() is not NotSet + assert NotSet not in state.values() + + +def test_real_defaults_still_prefill(): + param = Sig(_foo_with_not_set()).parameters["e"] + element, _ = _render_input(IntInputBase, param) + assert element.view_value == 3 + + +def test_input_specs_do_not_infer_type_from_not_set(): + inputs = {int: {"min_value": 0}, str: {"placeholder": "?"}} + with_not_set = mk_input_element_specs(_foo_with_not_set(), inputs) + plain = mk_input_element_specs(_foo, inputs) + + def without_obj(spec): + return {k: v for k, v in spec.items() if k != "obj"} + + for name in ("a", "b", "c", "d", "e"): + assert without_obj(with_not_set[name]) == without_obj(plain[name]) + + +def test_pydantic_specs_treat_not_set_as_required(): + specs = dict(func_to_pyd_model_specs(_foo_with_not_set())) + assert specs == dict(func_to_pyd_model_specs(_foo)) + assert specs["a"] == (int, ...) + assert specs["e"] == (int, 3) From f2fde080c1b69f6d719d320fdb459e535fb88df8 Mon Sep 17 00:00:00 2001 From: Thor Whalen <1906276+thorwhalen@users.noreply.github.com> Date: Tue, 22 Sep 2026 15:23:26 +0000 Subject: [PATCH 2/2] test: sharpen NotSet regression tests; import fallback sentinel eagerly (review follow-up) The fixture no longer mutates _foo in place (Sig.__call__ sets __signature__ on its argument), so comparisons against the plain signature are meaningful; 6 of 8 tests now fail without the fix. The i2 fallback imports i2.deco.NotSet at import time instead of on every call. Co-Authored-By: Claude Opus 5 --- front/util.py | 5 ++-- tests/test_not_set_defaults.py | 47 +++++++++++++++++++++------------- 2 files changed, 31 insertions(+), 21 deletions(-) diff --git a/front/util.py b/front/util.py index a789e32a..9b72a760 100644 --- a/front/util.py +++ b/front/util.py @@ -26,12 +26,11 @@ 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.""" - from i2.deco import NotSet - - return x is NotSet + return x is _NotSet def param_default(param): diff --git a/tests/test_not_set_defaults.py b/tests/test_not_set_defaults.py index e5a26375..36697309 100644 --- a/tests/test_not_set_defaults.py +++ b/tests/test_not_set_defaults.py @@ -20,23 +20,25 @@ ) from front.data_binding import BoundData from front.py2pydantic import func_to_pyd_model_specs -from front.util import param_default +from front.util import _annotate_func_arguments, param_default def _foo(a: int, b: float, c: str, d, e: int = 3): return a, b, c, d, e -def _foo_with_not_set(): - """``_foo``'s signature with ``NotSet`` defaults, as a re-landed #88 would show it.""" - sig = Sig(_foo) - return sig.ch_defaults( - **{ - name: NotSet - for name in sig.names - if sig.parameters[name].default is Parameter.empty - } - )(_foo) +def _foo_with_not_set( + a: int = NotSet, b: float = NotSet, c: str = NotSet, d=NotSet, e: int = 3 +): + """``_foo`` with ``NotSet`` defaults, as a re-landed #88 would show its signature. + + Defined separately (rather than with ``Sig(_foo).ch_defaults(...)(_foo)``), since + ``Sig.__call__`` sets ``__signature__`` on ``_foo`` itself, in place. + """ + return a, b, c, d, e + + +_foo_with_not_set.__name__ = _foo.__name__ # same input keys as _foo def _render_input(cls, param): @@ -55,8 +57,7 @@ def render(self): def test_param_default_maps_not_set_to_empty(): - func = _foo_with_not_set() - params = Sig(func).parameters + params = Sig(_foo_with_not_set).parameters assert params["a"].default is NotSet # the fixture really has NotSet defaults assert [param_default(p) for p in params.values()] == [Parameter.empty] * 4 + [3] @@ -66,7 +67,7 @@ def test_param_default_maps_not_set_to_empty(): [(IntInputBase, "a", 0), (FloatInputBase, "b", 0.0), (TextInputBase, "c", "")], ) def test_inputs_are_not_prefilled_with_not_set(cls, name, expected_view): - param = Sig(_foo_with_not_set()).parameters[name] + param = Sig(_foo_with_not_set).parameters[name] element, state = _render_input(cls, param) # used to raise on int(NotSet) assert element.view_value == expected_view assert element.value.get() is not NotSet @@ -74,14 +75,15 @@ def test_inputs_are_not_prefilled_with_not_set(cls, name, expected_view): def test_real_defaults_still_prefill(): - param = Sig(_foo_with_not_set()).parameters["e"] + param = Sig(_foo_with_not_set).parameters["e"] element, _ = _render_input(IntInputBase, param) assert element.view_value == 3 def test_input_specs_do_not_infer_type_from_not_set(): - inputs = {int: {"min_value": 0}, str: {"placeholder": "?"}} - with_not_set = mk_input_element_specs(_foo_with_not_set(), inputs) + # Keying an input spec on the sentinel's type would catch type inference from it + inputs = {int: {"min_value": 0}, str: {"placeholder": "?"}, type(NotSet): {"x": 1}} + with_not_set = mk_input_element_specs(_foo_with_not_set, inputs) plain = mk_input_element_specs(_foo, inputs) def without_obj(spec): @@ -92,7 +94,16 @@ def without_obj(spec): def test_pydantic_specs_treat_not_set_as_required(): - specs = dict(func_to_pyd_model_specs(_foo_with_not_set())) + specs = dict(func_to_pyd_model_specs(_foo_with_not_set)) assert specs == dict(func_to_pyd_model_specs(_foo)) assert specs["a"] == (int, ...) assert specs["e"] == (int, 3) + + +def test_annotation_from_default_type_ignores_not_set(): + def changes(func): + return dict( + _annotate_func_arguments(func, annot_for_dflt_type={type(NotSet): str}) + ) + + assert changes(_foo_with_not_set) == changes(_foo) == {}