Repository: mit-ll-responsible-ai/hydra-zen
Pull request #497 (2023-06-30 14:49:55)

Update `builds` signature inspection for python 3.11.4 change
https://github.com/python/cpython/issues/105080 updates the behavior of `inspect.signature` to walk through a class' mro in search of its signature, rather than just defer to the most recent definition of `__new__`.

This PR updates the signature inspection logic of `builds` to match this new behavior of `inspect.signature` across all versions of Python. I.e. this effectively serves as a backport.

Here is an example that exhibits this change in behavior:

```python
class A:
    def __new__(cls, a: int):
        return object.__new__(cls)

class B(A):
    def __init__(self, b: float):  ...

class C(B):
    ...
```

Before:

The signature of `A.__new__` was preferred over that of `B.__init__` even though the latter is closer to `C` in the inheritance hierarchy.

```python
>>> import inspect
>>> from hydra_zen import inspect
>>> inspect.signature(builds(c, populate_full_signature=True))
<Signature (a: int) -> None>
```

After:

C's signature is that of `B.__init__`

```python
>>> import inspect
>>> from hydra_zen import inspect
>>> inspect.signature(builds(c, populate_full_signature=True))
<Signature (b: float) -> None>
```


Patch:
diff --git a/docs/source/changes.rst b/docs/source/changes.rst
index 49140b09..167efe23 100644
--- a/docs/source/changes.rst
+++ b/docs/source/changes.rst
@@ -51,7 +51,7 @@ hydra-zen now uses the `trusted publishers <https://blog.pypi.org/posts/2023-04-
 Improvements
 ------------
 - :func:`~hydra_zen.builds` now has a transitive property that enables iterative build patterns. See :pull:`455`
-- :func:`~hydra_zen.zen`'s instantiation phase has been improved so that dataclass objects and stdlib containers are returned instead of omegaconf objects. See :pull:`#448`. 
+- :func:`~hydra_zen.zen`'s instantiation phase has been improved so that dataclass objects and stdlib containers are returned instead of omegaconf objects. See :pull:`448`. 
 - :func:`~hydra_zen.zen` can now be passed `resolve_pre_call=False` to defer the resolution of interpolated fields until after `pre_call` functions are called. See :pull:`460`.
 
 Bug Fixes
@@ -68,6 +68,7 @@ Most of these changes will not have any impact on users, based on download stati
 - :func:`~hydra_zen.just` not longer returns a frozen dataclass (see :pull:`459`).
 - Users that relied on patterns like `builds(builds(...))` will find that :pull:`455` has changed their behaviors. This new behavior can be disabled via `builds(..., zen_convert={'flat_target': False})`
 - :func:`~hydra_zen.zen`'s instantiation behavior was changed by :pull:`448`. See that PR for instructions on restoring the old behavior.
+- The signature-inspection logic of :func:`~hydra_zen.builds` has been modified to adopt and backport a fix made to :py:func:`inspect.signature` in Python 3.11.4. See :pull:`497`.
 
 --------------------------
 Documentation - 2023-03-11
diff --git a/src/hydra_zen/structured_configs/_implementations.py b/src/hydra_zen/structured_configs/_implementations.py
index b189c9ae..7fdbeabb 100644
--- a/src/hydra_zen/structured_configs/_implementations.py
+++ b/src/hydra_zen/structured_configs/_implementations.py
@@ -804,6 +804,26 @@ def sanitized_field(
     return _utils.field(default=value, init=init)
 
 
+def _get_sig_obj(target):
+    if not inspect.isclass(target):
+        return target
+
+    # This implements the same method prioritization as
+    # `inspect.signature` for Python >= 3.9.1
+    if "__new__" in target.__dict__:
+        return target.__new__
+    if "__init__" in target.__dict__:
+        return target.__init__
+
+    if len(target.__mro__) > 2:
+        for parent in target.__mro__[1:-1]:
+            if "__new__" in parent.__dict__:
+                return target.__new__
+            elif "__init__" in parent.__dict__:
+                return target.__init__
+    return getattr(target, "__init__", target)
+
+
 # partial=False, pop-sig=True; no *args, **kwargs, nor builds_bases
 @overload
 def builds(
@@ -1854,6 +1874,8 @@ def builds(
             )
         )
 
+    _sig_target = _get_sig_obj(target)
+
     try:
         # We want to rely on `inspect.signature` logic for raising
         # against an uninspectable sig, before we start inspecting
@@ -1884,14 +1906,9 @@ def builds(
         # This looks specifically for the scenario that the target
         # has inherited from a parent that implements __new__ and
         # the target implements only __init__.
-        if (
-            inspect.isclass(target)
-            and len(target.__mro__) > 2
-            and "__init__" in target.__dict__
-            and "__new__" not in target.__dict__
-            and any("__new__" in parent.__dict__ for parent in target.__mro__[1:-1])
-        ):
-            _params = tuple(inspect.signature(target.__init__).parameters.items())
+
+        if _sig_target is not target:
+            _params = tuple(inspect.signature(_sig_target).parameters.items())
 
             if (
                 _params and _params[0][1].kind is not _VAR_POSITIONAL
@@ -1940,25 +1957,9 @@ def builds(
     # `get_type_hints` properly resolves forward references, whereas annotations from
     # `inspect.signature` do not
     try:
-        if inspect.isclass(target):
-            # This implements the same method prioritization as
-            # `inspect.signature` for Python >= 3.9.1
-            if "__new__" in target.__dict__:
-                _annotation_target = target.__new__
-            elif "__init__" in target.__dict__:
-                _annotation_target = target.__init__
-            elif len(target.__mro__) > 2 and any(
-                "__new__" in parent.__dict__ for parent in target.__mro__[1:-1]
-            ):
-                _annotation_target = target.__new__
-            else:
-                _annotation_target = target.__init__
-        else:
-            _annotation_target = target
-
-        type_hints = get_type_hints(_annotation_target)
+        type_hints = get_type_hints(_sig_target)
 
-        del _annotation_target
+        del _sig_target
         # We don't need to pop self/class because we only make on-demand
         # requests from `type_hints`
 


Test patch:
diff --git a/tests/test_signature_parsing.py b/tests/test_signature_parsing.py
index fc553aaf..22055b0a 100644
--- a/tests/test_signature_parsing.py
+++ b/tests/test_signature_parsing.py
@@ -510,32 +510,32 @@ def test_populate_annotated_enum_regression():
 class A:
     # Manually verified using `inspect.signature` after
     # the fix of https://bugs.python.org/issue40897
-    py_310_sig = (("a", int),)
+    expected_sig = (("a", int),)
 
     def __new__(cls, a: int):
         return object.__new__(cls)
 
 
 class B(A):
-    py_310_sig = (("b", float),)
+    expected_sig = (("b", float),)
 
     def __init__(self, b: float):
         pass
 
 
 class C(A):
-    py_310_sig = (("c", str),)
+    expected_sig = (("c", str),)
 
     def __new__(cls, c: str):
         return object.__new__(cls)
 
 
 class D(A):
-    py_310_sig = (("a", int),)
+    expected_sig = (("a", int),)
 
 
 class E(B):
-    py_310_sig = (("a", int),)
+    expected_sig = (("b", float),)
 
 
 @pytest.mark.parametrize("Obj", [A, B, C, D, E])
@@ -548,7 +548,7 @@ def test_parse_sig_with_new_vs_init(Obj):
         (p.name, p.annotation) for p in inspect.signature(Conf).parameters.values()
     )
 
-    assert sig_via_builds == Obj.py_310_sig
+    assert sig_via_builds == Obj.expected_sig
 
 
 def test_Counter():