Repository: mit-ll-responsible-ai/hydra-zen
Pull request #84 (2021-08-11 02:38:54)

Applies type auto-widening to accommodate Builds-as-default
Hydra's type-checking occurs prior to recursive instantiation. This means that, e.g., passing a type-`Builds[int]` argument to a field `x: int` will trigger Hydra's type-checking upon recursive instantiation, even though the recursive instantiation will appropriately produce `int` for that argument.

To address this, hydra-zen now applies type auto-widening to accommodate a case where a user supplies a `Builds`-type argument to a non-`Any` field (addresses https://github.com/facebookresearch/hydra/issues/1759)

Given:

```python
def returns_int() -> int:
    return 1


def expects_int(x: int) -> int:
    returns x
```

Before:



```python
>>> instantiate(builds(expects_int, x=builds(returns_int))) 
---------------------------------------------------------------------------
ValidationError: Invalid type assigned: Builds_returns_int is not a subclass of int. value: <class 'types.Builds_returns_int'>
    full_key: x
    object_type=None
```

After:

```python
>>> instantiate(builds(expects_int, x=builds(returns_int))) 
1

>>> instantiate(builds(expects_int, x=builds(returns_int), hydra_recursive=False))  # type-broadening only occurs in recursive cases 
---------------------------------------------------------------------------
ValidationError: Invalid type assigned: Builds_returns_int is not a subclass of int. value: <class 'types.Builds_returns_int'>
    full_key: x
    object_type=None
```


Patch:
diff --git a/src/hydra_zen/structured_configs/_implementations.py b/src/hydra_zen/structured_configs/_implementations.py
index 2afbc11c..bf5890e5 100644
--- a/src/hydra_zen/structured_configs/_implementations.py
+++ b/src/hydra_zen/structured_configs/_implementations.py
@@ -771,7 +771,16 @@ def builds(
     user_specified_named_params: Dict[str, Tuple[str, type, Field]] = {
         name: (
             name,
-            _utils.sanitized_type(type_hints.get(name, Any)),
+            _utils.sanitized_type(type_hints.get(name, Any))
+            # OmegaConf's type-checking occurs before instantiation occurs.
+            # This means that, e.g., passing `Builds[int]` to a field `x: int`
+            # will fail Hydra's type-checking upon instantiation, even though
+            # the recursive instantiation will appropriately produce `int` for
+            # that field. This will not be addressed by hydra/omegaconf:
+            #    https://github.com/facebookresearch/hydra/issues/1759
+            # Thus we will auto-broaden the annotation when we see that the user
+            # has specified a `Builds` as a default value.
+            if not isinstance(value, Builds) or hydra_recursive is False else Any,
             sanitized_default_value(value),
         )
         for name, value in kwargs_for_target.items()


Test patch:
diff --git a/tests/test_protocols.py b/tests/test_protocols.py
index b335a77b..184b6da3 100644
--- a/tests/test_protocols.py
+++ b/tests/test_protocols.py
@@ -1,6 +1,7 @@
 # Copyright (c) 2021 Massachusetts Institute of Technology
 # SPDX-License-Identifier: MIT
 
+from dataclasses import dataclass
 from functools import partial
 
 import pytest
@@ -31,3 +32,18 @@ def test_Builds_is_not_PartialBuilds():
 
     PConf = builds(dict, hydra_partial=True)
     assert isinstance(PConf, Builds)
+
+
+def test_targeted_dataclass_is_Builds():
+    @dataclass
+    class NonTargeted:
+        pass
+
+    @dataclass
+    class Targeted:
+        _target_: str = "hello"
+
+    assert not isinstance(NonTargeted, Builds)
+    assert not isinstance(NonTargeted(), Builds)
+    assert isinstance(Targeted, Builds)
+    assert isinstance(Targeted(), Builds)
diff --git a/tests/test_signature_parsing.py b/tests/test_signature_parsing.py
index 65fba138..0e23d91c 100644
--- a/tests/test_signature_parsing.py
+++ b/tests/test_signature_parsing.py
@@ -10,6 +10,7 @@ from typing import Any, Callable, Dict, Mapping, Optional, Tuple, Type
 import hypothesis.strategies as st
 import pytest
 from hypothesis import given, settings
+from omegaconf.errors import ValidationError
 
 from hydra_zen import builds, hydrated_dataclass, instantiate, mutable_value, to_yaml
 from hydra_zen.typing import Just
@@ -273,3 +274,31 @@ class A_w_fwd_ref:
 def test_sig_with_unresolved_fwd_ref(obj):
     # builds should gracefully skip signature parsing for unresolved fwd-references
     instantiate(builds(obj, x=1))
+
+
+def returns_int() -> int:
+    return 1
+
+
+def expects_int(x: int) -> int:
+    return x
+
+
+@pytest.mark.parametrize(
+    "builds_as_default",
+    [
+        builds(returns_int),  # type
+        builds(returns_int)(),  # instance
+    ],
+)
+@pytest.mark.parametrize("hydra_recursive", [True, None])
+def test_setting_default_with_Builds_widens_type(builds_as_default, hydra_recursive):
+    # tests that we address https://github.com/facebookresearch/hydra/issues/1759
+    # via auto type-widening
+    kwargs = {} if hydra_recursive is None else dict(hydra_recursive=hydra_recursive)
+    b = builds(expects_int, x=builds_as_default, **kwargs)
+    assert 1 == instantiate(b)  # should not raise ValidationError
+
+    with pytest.raises(ValidationError):
+        # ensure that type annotation is broadened only when hydra_recursive=False
+        instantiate(builds(expects_int, x=builds_as_default, hydra_recursive=False))