From ada9a6e3c5127abdd9363ec54a198a270357b9fe Mon Sep 17 00:00:00 2001 From: Thor Whalen <1906276+thorwhalen@users.noreply.github.com> Date: Tue, 20 Oct 2026 18:11:46 +0000 Subject: [PATCH] Add DefaultPlaceholder and is_placeholder_default; InstanceAttrDefault is one A methodize(default_from=...) signature default is an InstanceAttrDefault marker, not a value. Consumers that serialize signature defaults (front forms, py2http OpenAPI) should skip it as they skip NotSet; is_placeholder_default is the one predicate for both. Test: a JSON dump of a methodized function's Sig defaults. Closes #120 Co-Authored-By: Claude Opus 5.5 --- i2/__init__.py | 2 ++ i2/deco.py | 30 +++++++++++++++++++++++++ i2/tests/test_partials_and_methodize.py | 22 ++++++++++++++++++ i2/wrapper.py | 15 ++++++++----- 4 files changed, 64 insertions(+), 5 deletions(-) diff --git a/i2/__init__.py b/i2/__init__.py index 522f858d..da602a5e 100644 --- a/i2/__init__.py +++ b/i2/__init__.py @@ -10,6 +10,8 @@ double_up_as_factory, NotSet, # Sentinel for "no value given" (distinct from None and Parameter.empty) is_not_set, # Test whether a value (e.g. a signature default) is NotSet + DefaultPlaceholder, # Base for signature defaults that stand for a value + is_placeholder_default, # NotSet or a DefaultPlaceholder: "no concrete default" ) from i2.signatures import ( diff --git a/i2/deco.py b/i2/deco.py index 2c7e50b3..e07a6181 100644 --- a/i2/deco.py +++ b/i2/deco.py @@ -86,6 +86,36 @@ def is_not_set(x) -> bool: return x is NotSet +class DefaultPlaceholder: + """Base class for signature defaults that stand for a value rather than being one. + + A default whose value is only known at call time (e.g. ``methodize``'s + ``default_from``, whose default is read from the instance) is shown in the + signature as an instance of a subclass. It is not a concrete value, so code that + serializes signature defaults (form generators, OpenAPI/JSON schemas) should treat + it as "no default": see :func:`is_placeholder_default`. + """ + + +def is_placeholder_default(x) -> bool: + """Return ``True`` if ``x`` is a signature default that is not a concrete value. + + That is, the ``NotSet`` sentinel or a :class:`DefaultPlaceholder` (such as + ``i2.wrapper.InstanceAttrDefault``). Signature consumers that need a concrete, + serializable default (UI or schema generators) can treat these like + ``inspect.Parameter.empty``: + + >>> from inspect import Parameter + >>> class Later(DefaultPlaceholder): + ... pass + >>> is_placeholder_default(NotSet), is_placeholder_default(Later()) + (True, True) + >>> is_placeholder_default(None), is_placeholder_default(Parameter.empty) + (False, False) + """ + return is_not_set(x) or isinstance(x, DefaultPlaceholder) + + # --------------------------------------------------------------------------------------- # TODO: https://github.com/i2mint/i2/issues/48 class FuncFactory: diff --git a/i2/tests/test_partials_and_methodize.py b/i2/tests/test_partials_and_methodize.py index 9f3b50b7..6dc70412 100644 --- a/i2/tests/test_partials_and_methodize.py +++ b/i2/tests/test_partials_and_methodize.py @@ -82,6 +82,28 @@ class K: assert (k.bar(3), k.bar(3, 10), k.bar(3, z=0)) == (7, 31, 1) +def test_methodize_default_from_json_dump_of_signature_defaults(): + """A default_from default is a placeholder: JSON consumers skip it, like NotSet.""" + import json + + from i2 import NotSet, is_placeholder_default + + def foo(x, y, z, w=NotSet, v=1): + return x + y * z + + @dataclass + class K: + x: int + zoo: int = 2 + bar = methodize(foo, bind="x", default_from=[("z", "zoo")]) + + defaults = Sig(K(1).bar).defaults + with pytest.raises(TypeError): + json.dumps(defaults) # what a naive consumer would meet + concrete = {k: v for k, v in defaults.items() if not is_placeholder_default(v)} + assert json.loads(json.dumps(concrete)) == {"v": 1} + + def test_methodize_keeps_self_before_position_only_params(): """i2mint/i2#118: ``self`` used to land after the ``/``.""" diff --git a/i2/wrapper.py b/i2/wrapper.py index 198bd834..2c42fd41 100644 --- a/i2/wrapper.py +++ b/i2/wrapper.py @@ -95,7 +95,7 @@ ) from i2.errors import OverwritesNotAllowed from i2.multi_object import Pipe -from i2.deco import double_up_as_factory +from i2.deco import double_up_as_factory, DefaultPlaceholder from i2.util import deprecation_of # --------------------------------------------------------------------------------------- @@ -1854,14 +1854,19 @@ def pairs(): return param_to_attr -class InstanceAttrDefault: +class InstanceAttrDefault(DefaultPlaceholder): """The default of a method param that is sourced from an instance attribute. Shown in the method's signature as ``self.`` (see ``methodize``'s - ``default_from``). + ``default_from``). It is a ``DefaultPlaceholder``, not a value: its value depends + on the instance, which a schema or form generator does not have, so such code + should treat it as "no default" (``i2.is_placeholder_default``). >>> InstanceAttrDefault('zoo') self.zoo + >>> from i2 import is_placeholder_default + >>> is_placeholder_default(InstanceAttrDefault('zoo')) + True """ def __init__(self, attr: str, *, instance_arg_name: str = "self"): @@ -2175,8 +2180,8 @@ def methodize( 16 The signature default is an ``InstanceAttrDefault`` marker (shown ``self.zoo``), - not a value: code that serializes signature defaults (e.g. to JSON) should not - expect a JSON-able one there. + not a value: code that serializes signature defaults (e.g. to JSON) should skip + it, as it skips ``NotSet``, by testing ``i2.is_placeholder_default``. """ return func_to_method_func( func,