Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions front/elements/elements.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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()
Expand Down
11 changes: 7 additions & 4 deletions front/py2pydantic.py
Original file line number Diff line number Diff line change
Expand Up @@ -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``.
Expand Down Expand Up @@ -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, ...)

Expand Down
33 changes: 32 additions & 1 deletion front/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,44 @@
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
from i2.signatures import name_of_obj

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))
<class 'inspect._empty'>
>>> param_default(Parameter('x', Parameter.KEYWORD_ONLY))
<class 'inspect._empty'>
"""
default = param.default
return Parameter.empty if is_not_set(default) else default


ignore_import_problems = suppress(ImportError, ModuleNotFoundError)


Expand Down Expand Up @@ -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_):
Expand Down
109 changes: 109 additions & 0 deletions tests/test_not_set_defaults.py
Original file line number Diff line number Diff line change
@@ -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) == {}
Loading