diff --git a/i2/__init__.py b/i2/__init__.py index e7ecdf73..b784d57b 100644 --- a/i2/__init__.py +++ b/i2/__init__.py @@ -8,6 +8,8 @@ input_output_decorator, wrap_class_methods_input_and_output, 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 ) from i2.signatures import ( diff --git a/i2/deco.py b/i2/deco.py index 4964828c..8b1e6031 100644 --- a/i2/deco.py +++ b/i2/deco.py @@ -55,6 +55,35 @@ def _not_set_repr(self): NotSet = mk_sentinel("NotSet", repr_=_not_set_repr) +NotSet.__doc__ = """Sentinel meaning "no value was given for this argument". + +It is distinct from ``None`` (which can be a legitimate value) and from +``inspect.Parameter.empty`` (which means "this parameter has no default"). +Code that reads signature defaults should test for it with :func:`is_not_set` +rather than by comparing to a private object. Note that ``bool(NotSet)`` is +``False``, so ``if not default:`` would lump it together with ``None``, ``0`` and +``""``: use :func:`is_not_set` instead. +""" + + +def is_not_set(x) -> bool: + """Return ``True`` if ``x`` is the ``NotSet`` sentinel, and ``False`` otherwise. + + Signature consumers (UI or schema generators, for example) can use it to treat a + ``NotSet`` default like ``inspect.Parameter.empty``, i.e. "required, no default": + + >>> from inspect import Parameter + >>> is_not_set(NotSet) + True + >>> is_not_set(None), is_not_set(Parameter.empty), is_not_set("NotSet") + (False, False, False) + >>> def default_or_empty(param): + ... return Parameter.empty if is_not_set(param.default) else param.default + >>> p = Parameter('x', Parameter.KEYWORD_ONLY, default=NotSet) + >>> default_or_empty(p) is Parameter.empty + True + """ + return x is NotSet # --------------------------------------------------------------------------------------- diff --git a/i2/tests/test_deco.py b/i2/tests/test_deco.py index b7523787..2acafa73 100644 --- a/i2/tests/test_deco.py +++ b/i2/tests/test_deco.py @@ -37,3 +37,20 @@ def test_func_factory_signature_keeps_required_params_required(): # The factory itself can still be called with none to all of the arguments assert factory()(1, 2, "a") == [1, 2, "a", "-"] assert factory(chk_size=3)(1, name="b") == [1, 3, "b", "-"] + + +def test_not_set_is_exported_and_recognisable(): + """``NotSet`` and ``is_not_set`` are public, so signature consumers can recognise + the sentinel without importing a private object (see i2mint/i2#48).""" + import pickle + + import i2 + from i2.deco import NotSet as deco_not_set + + assert i2.NotSet is deco_not_set + assert i2.is_not_set(i2.NotSet) + for other in (None, inspect.Parameter.empty, "NotSet", 0, False, object()): + assert not i2.is_not_set(other) + # Survives pickling as the same object (so identity checks stay valid) + assert pickle.loads(pickle.dumps(i2.NotSet)) is i2.NotSet + assert repr(i2.NotSet) == "NotSet"