Repository: mit-ll-responsible-ai/hydra-zen

Issue:
builds should raise when _target_ points to inaccessible path (e.g. <local>)



Discussion:

Resolving patch:
diff --git a/src/hydra_utils/structured_configs/_implementations.py b/src/hydra_utils/structured_configs/_implementations.py
--- a/src/hydra_utils/structured_configs/_implementations.py
+++ b/src/hydra_utils/structured_configs/_implementations.py
@@ -15,7 +15,7 @@
     overload,
 )
 
-from typing_extensions import Literal
+from typing_extensions import Final, Literal
 
 from hydra_utils.funcs import identity, partial
 from hydra_utils.structured_configs import _utils
@@ -23,6 +23,37 @@
 
 __all__ = ["builds", "just", "hydrated_dataclass", "mutable_value"]
 
+_TARGET_FIELD_NAME: Final[str] = "_target_"
+_PARTIAL_TARGET_FIELD_NAME: Final[str] = "_partial_target_"
+
+
+def _check_importable_path(obj: Any):
+    """
+    Raises if `obj` is defined in unreachable namespace
+
+    Parameters
+    ----------
+    obj : Any
+
+    Raises
+    ------
+    ModuleNotFoundError
+
+    Examples
+    --------
+    >>> class C:
+    ...     def f(self): pass
+    >>> _check_importable_path(C)  # OK
+    >>> _check_importable_path(C.f)  # raises
+    """
+    path = _utils.get_obj_path(obj)
+    # catches "<locals>" and "<unknown>"
+    if "<" in path:
+        name = _utils.safe_name(obj)
+        raise ModuleNotFoundError(
+            f"{name} is not importable from path: {_utils.get_obj_path(obj)}"
+        )
+
 
 def mutable_value(x: Any) -> Field:
     """Used to set a mutable object as a default value for a field
@@ -219,11 +250,14 @@ def just(obj: Importable) -> Just[Importable]:
         raise AttributeError(
             f"`just({obj})`: `obj` is not importable; it is missing the attributes `__module__` and/or `__qualname__`"
         )
+
+    _check_importable_path(obj)
+
     out_class = make_dataclass(
         ("Just_" + _utils.safe_name(obj)),
         [
             (
-                "_target_",
+                _TARGET_FIELD_NAME,
                 str,
                 field(default=_utils.get_obj_path(identity), init=False),
             ),
@@ -550,14 +584,24 @@ def builds(
             + "`builds(..., hydra_partial=True)` requires that `hydra_recursive=True`"
         )
 
+    _check_importable_path(target)
+
     if hydra_partial is True:
         target_field = [
-            ("_target_", str, field(default=_utils.get_obj_path(partial), init=False)),
-            ("_partial_target_", Any, field(default=just(target), init=False)),
+            (
+                _TARGET_FIELD_NAME,
+                str,
+                field(default=_utils.get_obj_path(partial), init=False),
+            ),
+            (_PARTIAL_TARGET_FIELD_NAME, Any, field(default=just(target), init=False)),
         ]
     else:
         target_field = [
-            ("_target_", str, field(default=_utils.get_obj_path(target), init=False))
+            (
+                _TARGET_FIELD_NAME,
+                str,
+                field(default=_utils.get_obj_path(target), init=False),
+            )
         ]
 
     # `base_fields` stores the list of fields that will be present in our dataclass
@@ -721,7 +765,7 @@ def builds(
 
     out = make_dataclass(dataclass_name, fields=base_fields, bases=builds_bases)
 
-    if hydra_partial is False and hasattr(out, "_partial_target_"):
+    if hydra_partial is False and hasattr(out, _PARTIAL_TARGET_FIELD_NAME):
         # `out._partial_target_` has been inherited; this will lead to an error when
         # hydra-instantiation occurs, since it will be passed to target.
         # There is not an easy way to delete this, since it comes from a parent class


Test patch:
diff --git a/tests/test_dataclass_semantics.py b/tests/test_dataclass_semantics.py
--- a/tests/test_dataclass_semantics.py
+++ b/tests/test_dataclass_semantics.py
@@ -11,10 +11,12 @@ def f_three_vars(x, y, z):
     return x, y, z
 
 
+def f(x, y):
+    return x, y
+
+
 @given(full_sig=st.booleans(), partial=st.booleans())
 def test_builds_produces_dataclass(full_sig: bool, partial: bool):
-    def f(x, y):
-        return x, y
 
     if full_sig:
         Builds_f = builds(f, populate_full_signature=full_sig, hydra_partial=partial)
@@ -26,16 +28,18 @@ def f(x, y):
     assert out.y == -1.0
 
 
+def f_2(x, y, z):
+    pass
+
+
 @pytest.mark.parametrize("full_sig", [True, False])
 @pytest.mark.parametrize("partial", [True, False])
 def test_chain_builds_of_targets_with_common_interfaces(full_sig, partial):
-    def f(x, y, z):
-        pass
 
     # Note that conf_1 and conf_2 target `f` whereas conf_3 targets `f_three_vars`,
     # which have identical interfaces.
-    conf_1 = builds(f, x=1)
-    conf_2 = builds(f, y=2, builds_bases=(conf_1,))
+    conf_1 = builds(f_2, x=1)
+    conf_2 = builds(f_2, y=2, builds_bases=(conf_1,))
     conf_3 = builds(
         f_three_vars,
         z=3,
diff --git a/tests/test_signature_parsing.py b/tests/test_signature_parsing.py
--- a/tests/test_signature_parsing.py
+++ b/tests/test_signature_parsing.py
@@ -12,19 +12,21 @@
 Empty = Parameter.empty
 
 
+def f1(x=2):
+    return x
+
+
 @pytest.mark.parametrize("as_hydrated_dataclass", [False, True])
 @given(user_value=valid_hydra_literals, full_signature=st.booleans())
 def test_user_specified_value_overrides_default(
     user_value, as_hydrated_dataclass: bool, full_signature: bool
 ):
-    def f(x=2):
-        return x
 
     if not as_hydrated_dataclass:
-        BuildsF = builds(f, x=user_value, populate_full_signature=full_signature)
+        BuildsF = builds(f1, x=user_value, populate_full_signature=full_signature)
     else:
 
-        @hydrated_dataclass(f, populate_full_signature=full_signature)
+        @hydrated_dataclass(f1, populate_full_signature=full_signature)
         class BuildsF:
             x: Any = (
                 mutable_value(user_value)
@@ -36,16 +38,19 @@ class BuildsF:
     assert b.x == user_value
 
 
+def f2(x, y, z, has_default=101):
+    return x, y, z, has_default
+
+
 @settings(max_examples=1000)
 @given(
     user_value_x=valid_hydra_literals,
     user_value_y=valid_hydra_literals,
     user_value_z=valid_hydra_literals,
-    default_value=valid_hydra_literals,
     specified_as_default=st.lists(st.sampled_from(["x", "y", "z"]), unique=True),
 )
 def test_builds_signature_shuffling_takes_least_path(
-    user_value_x, user_value_y, user_value_z, default_value, specified_as_default
+    user_value_x, user_value_y, user_value_z, specified_as_default
 ):
 
     # We will specify an arbitrary selection of x, y, z via `builds`, and then specify the
@@ -57,8 +62,6 @@ def test_builds_signature_shuffling_takes_least_path(
     #  - `builds(f, populate_full_signature=True)`.__init__ -> (x, y, z, has_default=default_value)
     #  - `builds(f, x=1, populate_full_signature=True)`.__init__ -> (y, z, x=1, has_default=default_value)
     #  - `builds(f, y=2, z=-1, populate_full_signature=True)`.__init__ -> (z, y=2, z=-1, has_default=default_value)
-    def f(x, y, z, has_default=default_value):
-        return x, y, z, has_default
 
     defaults = dict(x=user_value_x, y=user_value_y, z=user_value_z)
 
@@ -67,7 +70,7 @@ def f(x, y, z, has_default=default_value):
         k: defaults[k] for k in set(defaults) - set(specified_as_default)
     }
 
-    BuildsF = builds(f, **default_override, populate_full_signature=True)
+    BuildsF = builds(f2, **default_override, populate_full_signature=True)
     sig_param_names = [p.name for p in inspect.signature(BuildsF).parameters.values()]
     expected_param_ordering = (
         sorted(specified_via_init) + sorted(specified_as_default) + ["has_default"]
@@ -79,7 +82,11 @@ def f(x, y, z, has_default=default_value):
     assert b.x == user_value_x
     assert b.y == user_value_y
     assert b.z == user_value_z
-    assert b.has_default == default_value
+    assert b.has_default == 101
+
+
+def f3(x: str, *args, y: int = 22, z=[2], **kwargs):
+    pass
 
 
 @pytest.mark.parametrize("include_extra_param", [False, True])
@@ -87,12 +94,10 @@ def f(x, y, z, has_default=default_value):
 def test_builds_with_full_sig_mirrors_target_sig(
     include_extra_param: bool, partial: bool
 ):
-    def target(x: str, *args, y: int = 22, z=[2], **kwargs):
-        pass
 
     kwargs = dict(named_param=2) if include_extra_param else {}
     kwargs["y"] = 0  # overwrite default value
-    Conf = builds(target, populate_full_signature=True, hydra_partial=partial, **kwargs)
+    Conf = builds(f3, populate_full_signature=True, hydra_partial=partial, **kwargs)
 
     params = inspect.signature(Conf).parameters.values()
 
diff --git a/tests/test_validation.py b/tests/test_validation.py
--- a/tests/test_validation.py
+++ b/tests/test_validation.py
@@ -44,14 +44,17 @@ def test_builds_raises_when_user_specified_arg_is_not_in_sig(func, full_sig, par
         builds(func, x=10, hydra_partial=partial, populate_full_signature=full_sig)
 
 
+@dataclass
+class A:
+    x: int = 1  # `x` is not a parameter in y
+
+
+def f(y):
+    return y
+
+
 @given(partial=st.booleans(), full_sig=st.booleans())
 def test_builds_raises_when_base_has_invalid_arg(full_sig, partial):
-    @dataclass
-    class A:
-        x: int = 1  # `x` is not a parameter in y
-
-    def f(y):
-        return y
 
     with pytest.raises(TypeError):
         builds(
@@ -94,26 +97,28 @@ def test_fuzz_build_validation_against_a_bunch_of_common_objects(
     builds(target, hydra_partial=partial, populate_full_signature=full_sig)
 
 
+def f2():
+    pass
+
+
 @given(partial=st.booleans(), full_sig=st.booleans())
 def test_builds_raises_when_base_with_partial_target_is_specified(
     partial: bool, full_sig: bool
 ):
-    def f():
-        pass
 
-    partiald_conf = builds(f, hydra_partial=True)
+    partiald_conf = builds(f2, hydra_partial=True)
 
     if not partial:
         with pytest.raises(TypeError):
             builds(
-                f,
+                f2,
                 populate_full_signature=full_sig,
                 hydra_partial=partial,
                 builds_bases=(partiald_conf,),
             )
     else:
         builds(
-            f,
+            f2,
             populate_full_signature=full_sig,
             hydra_partial=partial,
             builds_bases=(partiald_conf,),
@@ -170,7 +175,7 @@ def test_just_raises_with_legible_message():
     assert "just(1)" in str(exec_info.value)
 
 
-def test_hydratated_dataclass_from_instance_raise():
+def test_hydrated_dataclass_from_instance_raise():
     @dataclass
     class A:
         x: int = 1
@@ -178,3 +183,20 @@ class A:
     instance_of_a = A()
     with pytest.raises(NotImplementedError):
         hydrated_dataclass(dict)(instance_of_a)
+
+
+@given(partial=st.booleans(), full_sig=st.booleans())
+def test_builds_raises_for_unimportable_target(partial, full_sig):
+    def unreachable():
+        pass
+
+    with pytest.raises(ModuleNotFoundError):
+        builds(unreachable, hydra_partial=partial, populate_full_signature=full_sig)
+
+
+def test_just_raises_for_unimportable_target():
+    def unreachable():
+        pass
+
+    with pytest.raises(ModuleNotFoundError):
+        just(unreachable)