From 2b8c274528d2ac938f4ddcc442e1a2aad0c81a4f Mon Sep 17 00:00:00 2001 From: datvo06 Date: Mon, 8 Jun 2026 11:44:07 -0400 Subject: [PATCH] Widen nested_type Callable branch on introspection failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #673. The Callable branch of `nested_type` could raise on three real values where the documented `Box(type(value))` fallback was expected: - `pytest.mark.parametrize` (a `MarkDecorator`): `typing.get_overloads` raises `AttributeError` because `MarkDecorator` lacks `__qualname__`. - `dict.get` (a `method_descriptor`): same shape — missing `__module__`. - An `Operation` wrapping a callable with a stringified annotation that does not resolve in scope: `inspect.signature` consults `Operation.__signature__`, which calls `typing.get_type_hints`, which raises `NameError`. Per the canonicalize-widening principle (eb8680 on PR #613): when introspection cannot construct a more precise `TypeExpression`, the right move is to widen rather than propagate the failure. - Wrap `typing.get_overloads(value)` and `inspect.signature(value)` with broader `except` clauses (adding `AttributeError`, `TypeError`, `NameError`) that fall back to `Box(type(value))`. - The `Operation` branch propagates the widening when its Callable delegate returns a bare type rather than a parameterised one. Adds three regression tests pinning each failure mode. --- effectful/internals/unification.py | 27 ++++++++++-- tests/test_internals_unification.py | 67 +++++++++++++++++++++++++++++ 2 files changed, 91 insertions(+), 3 deletions(-) diff --git a/effectful/internals/unification.py b/effectful/internals/unification.py index e425bba6..ba770ad3 100644 --- a/effectful/internals/unification.py +++ b/effectful/internals/unification.py @@ -962,18 +962,39 @@ def _(value: effectful.ops.types.Term): @nested_type.register def _(value: effectful.ops.types.Operation): typ = nested_type.dispatch(collections.abc.Callable)(value).value - (arg_types, return_type) = typing.get_args(typ) + args = typing.get_args(typ) + if not args: + # Callable branch widened to `Box(type(value))` because + # introspection failed (#673); propagate the widening. + return Box(type(value)) + (arg_types, return_type) = args return Box(effectful.ops.types.Operation[arg_types, return_type]) # type: ignore @nested_type.register def _(value: collections.abc.Callable): - if typing.get_overloads(value): + # `typing.get_overloads(value)` reads `__qualname__`/`__module__` on + # the callable and raises `AttributeError` on values like + # `pytest.mark.parametrize` (a `MarkDecorator`) or `dict.get` (a + # `method_descriptor`). Treat that the same way as the + # no-signature fallback: widen to `Box(type(value))`. #673. + try: + if typing.get_overloads(value): + return Box(type(value)) + except (AttributeError, TypeError): return Box(type(value)) + # `inspect.signature(value)` may consult a custom `__signature__` + # property (e.g. `Operation.__signature__` calls + # `typing.get_type_hints`) which raises `NameError` when an + # annotation forward-ref cannot be resolved. Per canonicalize's + # widening principle (see eb8680 on PR #613), an unresolvable + # annotation does not mean the value isn't callable -- widen to + # `Box(type(value))` rather than propagating the introspection + # failure. #673. try: sig = inspect.signature(value) - except ValueError: + except (ValueError, TypeError, NameError, AttributeError): return Box(type(value)) if sig.return_annotation is inspect.Signature.empty: diff --git a/tests/test_internals_unification.py b/tests/test_internals_unification.py index 8b93976f..fc1a4ba1 100644 --- a/tests/test_internals_unification.py +++ b/tests/test_internals_unification.py @@ -867,6 +867,73 @@ def test_nested_type_term_error(): nested_type(mock_term) +def test_nested_type_marker_decorator_widens_to_class(): + """#673: ``pytest.mark.parametrize`` is a ``MarkDecorator`` instance -- + callable, but lacks ``__qualname__``. The Callable branch's + ``typing.get_overloads`` raises ``AttributeError``; per the + canonicalize-widening principle (PR #613) the fallback should be + ``Box(type(value))``.""" + val = pytest.mark.parametrize + assert nested_type(val).value is type(val) + + +def test_nested_type_method_descriptor_widens_to_class(): + """#673: ``dict.get`` is a ``method_descriptor`` -- callable but + missing ``__module__``. Same widening shape as MarkDecorator.""" + val = dict.get + assert nested_type(val).value is type(val) + + +def test_nested_type_unresolvable_forward_ref_widens(): + """#673: an ``Operation`` whose default carries a stringified + annotation whose name does not resolve in the captured scope. + ``inspect.signature`` consults ``Operation.__signature__`` which + calls ``typing.get_type_hints`` -- forward-ref evaluation raises + ``NameError``. ``nested_type`` should widen to ``Box(type(value))`` + rather than propagating.""" + from effectful.ops.syntax import defop + + def _hidden_module(): + class ClientSession: # noqa: F841 -- intentionally not visible to op + pass + + def real(x: "ClientSession") -> int: # noqa: F821 -- forward ref unresolved + raise NotImplementedError + + return real + + op = defop(_hidden_module()) + assert nested_type(op).value is type(op) + + +def test_nested_type_eager_annotation_produces_precise_type(): + """#673: when the annotation is NOT stringified, it resolves at + function-def time and ``typing.get_type_hints`` succeeds. + ``nested_type`` then has enough information to build the precise + ``Operation[[ClientSession], int]`` expression (rather than + widening to ``Box(type(value))``). Counterpart to the + forward-ref-widens test above.""" + import effectful.ops.types + from effectful.ops.syntax import defop + + def _eager_module(): + class ClientSession: + pass + + def real(x: ClientSession) -> int: + raise NotImplementedError + + return real, ClientSession + + real, ClientSession = _eager_module() + op = defop(real) + inferred = nested_type(op).value + assert typing.get_origin(inferred) is effectful.ops.types.Operation + arg_types, return_type = typing.get_args(inferred) + assert list(arg_types) == [ClientSession] + assert return_type is int + + def sequence_getitem[T](seq: collections.abc.Sequence[T], index: int) -> T: return seq[index]