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..9b72a760 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,36 @@ from front.types import Map +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 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 +303,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..36697309 --- /dev/null +++ b/tests/test_not_set_defaults.py @@ -0,0 +1,109 @@ +"""``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 _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( + 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): + @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(): + 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] + + +@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(): + # 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): + 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) + + +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) == {}