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

Issue:
Providing a class method as a target for builds
Error:

```
ImportError: Error instantiating 'hydra_zen.funcs.zen_processing' : Encountered error: `No module named 'pytorch_lightning.core.datamodule.from_datasets'; 'pytorch_lightning.core.datamodule' is not a package` when loading module 'pytorch_lightning.core.datamodule.from_datasets'
```

To reproduce:

```python
from hydra_zen import builds, MISSING
from pytorch_lightning import LightningDataModule
from torchvision import datasets

CIFAR10 = builds(datasets.CIFAR10,  root=MISSING,  download=True)

CIFAR10DataModule = builds(
    LightningDataModule.from_datasets,
    num_workers=4,
    batch_size=256,
    train_dataset=CIFAR10(root="${..data_dir}"),
    zen_meta=dict(data_dir=MISSING)
)

instantiate(CIFAR10DataModule, data_dir="/path/to/cifar10")
```

**edit**

This does not appear to be a `zen_processing` issue, but either a Hydra or hydra-zen issue.  Removing `zen_meta` above provides the following error:

```
Traceback (most recent call last):
  File "/home/justin_goodwin/projects/raiden/hydra-zen-example/image_classifier/image_classifier/main.py", line 38, in main
    data = instantiate(cfg.experiment.data)
  File "/home/justin_goodwin/.conda/envs/raiden/lib/python3.8/site-packages/hydra/_internal/instantiate/_instantiate2.py", line 180, in instantiate
    return instantiate_node(config, *args, recursive=_recursive_, convert=_convert_)
  File "/home/justin_goodwin/.conda/envs/raiden/lib/python3.8/site-packages/hydra/_internal/instantiate/_instantiate2.py", line 240, in instantiate_node
    _target_ = _resolve_target(node.get(_Keys.TARGET))
  File "/home/justin_goodwin/.conda/envs/raiden/lib/python3.8/site-packages/hydra/_internal/instantiate/_instantiate2.py", line 104, in _resolve_target
    return _locate(target)
  File "/home/justin_goodwin/.conda/envs/raiden/lib/python3.8/site-packages/hydra/_internal/utils.py", line 587, in _locate
    raise ImportError(
ImportError: Encountered error: `No module named 'pytorch_lightning.core.datamodule.from_datasets'; 'pytorch_lightning.core.datamodule' is not a package` when loading module 'pytorch_lightning.core.datamodule.from_datasets'
```

**edit edit**

An issue with `classmethods`:

```
>>> print(to_yaml(builds(LightningDataModule)))
_target_: pytorch_lightning.core.datamodule.LightningDataModule

>>> print(to_yaml(builds(LightningDataModule.from_datasets)))
_target_: pytorch_lightning.core.datamodule.from_datasets
```


Discussion:
Yep, this is an issue with providing a classmethod to builds. It occurs to me that we can actually accommodate class-method targets via `zen_processing`. We can use the process laid out [here](https://stackoverflow.com/a/19228282) to see if the provided target is actually a class method. And if it is, we can specify the following yaml:

```
_target_ = hydra_zen.funcs.zen_processing
_zen_target = LightningDataModule
_zen_method = from_datasets
```

And then `zen_processing` can be updated to handle `_zen_method` via something like:

```python
target_parent = get_obj(_zen_target)  # import class
target = getattr(target_parent, _zen_method)  # get class method

return target(*args, **kwargs)  # instantiate class method
```
 
This will be really nice to have for PL users, so perhaps `v0.4.0` can consist exclusively of adding this feature, and can quickly follow `0.3.0`
Update:

Actually, we totally did support class methods in `v0.2.0`!!

```python
class C:
    @classmethod
    def f(cls):
        return 1

>>> instantiate(builds(C.f))
1
```

So https://github.com/mit-ll-responsible-ai/hydra-zen/pull/116 actually was a compatibility-breaking PR. This is pretty nasty for anyone who had been builds-ing a class method! 

Changing this to a `v0.3.0` milestone and categorizing it as a bugfix.

I simply did not realize that Hydra supported this to begin with. The good new is that, because Hydra *does* support this, this will be a much simpler fix on our end than I had described above.

Resolving patch:
diff --git a/src/hydra_zen/structured_configs/_utils.py b/src/hydra_zen/structured_configs/_utils.py
--- a/src/hydra_zen/structured_configs/_utils.py
+++ b/src/hydra_zen/structured_configs/_utils.py
@@ -1,5 +1,6 @@
 # Copyright (c) 2021 Massachusetts Institute of Technology
 # SPDX-License-Identifier: MIT
+import inspect
 import sys
 import warnings
 from dataclasses import MISSING, Field, field as _field, is_dataclass
@@ -182,6 +183,32 @@ def safe_name(obj: Any, repr_allowed=True) -> str:
     return UNKNOWN_NAME
 
 
+def is_classmethod(obj) -> bool:
+    """
+    https://stackoverflow.com/a/19228282/6592114
+
+    Credit to: Martijn Pieters
+    License: CC BY-SA 4.0 (free to copy/redistribute/remix/transform)"""
+
+    if not inspect.ismethod(obj):
+        return False
+
+    bound_to = getattr(obj, "__self__", None)
+    if not isinstance(bound_to, type):
+        # must be bound to a class
+        return False
+    name = safe_name(obj)
+
+    if name == UNKNOWN_NAME:  # pragma: no cover
+        return False
+
+    for cls in bound_to.__mro__:
+        descriptor = vars(cls).get(name)
+        if descriptor is not None:
+            return isinstance(descriptor, classmethod)
+    return False  # pragma: no cover
+
+
 def building_error_prefix(target) -> str:
     return f"Building: {safe_name(target)} ..\n"
 
@@ -220,7 +247,10 @@ def get_obj_path(obj: Any) -> str:
         else:
             raise ModuleNotFoundError(f"{name} is not importable")
 
-    return f"{module}.{name}"
+    if not is_classmethod(obj):
+        return f"{module}.{name}"
+    else:
+        return f"{module}.{qualname}"
 
 
 NoneType = type(None)


Test patch:
diff --git a/tests/__init__.py b/tests/__init__.py
--- a/tests/__init__.py
+++ b/tests/__init__.py
@@ -3,6 +3,8 @@
 
 import hypothesis.strategies as st
 
+from hydra_zen.structured_configs._utils import is_classmethod
+
 valid_hydra_literals = st.sampled_from(
     [0, 1.0, True, "x", None, ["a", 1], {1: 1}, {"a": 1}]
 )
@@ -14,3 +16,10 @@ def everything_except(*excluded_types: type):
         .flatmap(st.from_type)
         .filter(lambda x: not isinstance(x, excluded_types))
     )
+
+
+def check_identity(new, original):
+    if not is_classmethod(original):
+        return new is original
+    # objects like classmethods do not satisfy `x is x`
+    return new == original
diff --git a/tests/test_documented_examples.py b/tests/test_documented_examples.py
--- a/tests/test_documented_examples.py
+++ b/tests/test_documented_examples.py
@@ -73,3 +73,13 @@ def test_nested_configs_pos_args():
     assert x == dict(w=1)
     assert y is f
     assert z is f
+
+
+class C:
+    @classmethod
+    def f(cls, x):
+        return x
+
+
+def test_builds_classmethod():
+    assert instantiate(builds(C.f, x=10)) == 10
diff --git a/tests/test_roundtrips.py b/tests/test_roundtrips.py
--- a/tests/test_roundtrips.py
+++ b/tests/test_roundtrips.py
@@ -18,7 +18,8 @@
 from omegaconf import OmegaConf
 
 from hydra_zen import builds, get_target, instantiate, just, to_yaml
-from tests import valid_hydra_literals
+from hydra_zen.structured_configs._utils import is_classmethod
+from tests import check_identity, valid_hydra_literals
 
 arbitrary_kwargs = st.dictionaries(
     keys=st.text(alphabet=string.ascii_letters, min_size=1, max_size=1),
@@ -113,7 +114,9 @@ def test_builds_roundtrips_with_mutable_values(
 
 
 class LocalClass:
-    pass
+    @classmethod
+    def a_class_method(cls):
+        return
 
 
 def local_function():
@@ -123,6 +126,7 @@ def local_function():
 a_bunch_of_objects = [
     local_function,
     LocalClass,
+    LocalClass.a_class_method,
     int,
     str,
     list,
@@ -146,11 +150,20 @@ def local_function():
 ]
 
 
+def with_is(x, y):
+    return x is y
+
+
+def with_eq(x, y):
+    return x == y
+
+
 @pytest.mark.parametrize("obj", a_bunch_of_objects)
 def test_just_roundtrip(obj):
+    # local classmethods have weird identity behaviors
     cfg = just(obj)
-    assert instantiate(cfg) is obj
-    assert instantiate(OmegaConf.create(to_yaml(cfg))) is obj
+    assert check_identity(instantiate(cfg), obj)
+    assert check_identity(instantiate(OmegaConf.create(to_yaml(cfg))), obj)
 
 
 @pytest.mark.parametrize("x", a_bunch_of_objects)
@@ -165,10 +178,10 @@ def test_just_roundtrip(obj):
 )
 def test_get_target_roundtrip(x, fn):
     conf = fn(x)
-    assert x is get_target(conf)
+    assert check_identity(x, get_target(conf))
 
     loaded = OmegaConf.create(to_yaml(conf))
-    assert x is get_target(loaded)
+    assert check_identity(x, get_target(loaded))
 
 
 @dataclass
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
@@ -201,6 +201,23 @@ def __init__(
     ):
         pass
 
+    @classmethod
+    def a_class_method(
+        cls,
+        x: int,
+        y: str,
+        z: bool,
+        a_tuple: Tuple[str] = ("hi",),
+        optional: Optional[int] = None,
+        inferred_optional_str: str = None,
+        inferred_optional_any: Mapping = None,
+        default: float = 100.0,
+        a_function: Callable = func,
+        a_class: Type[Dict] = dict,
+        a_dataclass: Type[ADataClass] = ADataClass,
+    ):
+        pass
+
 
 class AMetaClass(ABC):
     def __init__(
@@ -220,7 +237,7 @@ def __init__(
         pass
 
 
-@pytest.mark.parametrize("target", [a_func, AClass, AMetaClass])
+@pytest.mark.parametrize("target", [a_func, AClass, AClass.a_class_method, AMetaClass])
 @given(
     user_specified_values=st.dictionaries(
         keys=st.sampled_from(["x", "y", "z"]), values=st.integers(0, 3), max_size=3
@@ -237,7 +254,7 @@ def test_builds_partial_with_full_sig_excludes_non_specified_params(
         zen_partial=True,
     )
 
-    expected_sig = [
+    expected_sig = [  # type: ignore
         (var_name, name_to_type[var_name], user_specified_values[var_name])
         for var_name in sorted(user_specified_values)
     ] + [
diff --git a/tests/test_third_party/test_against_pytorch_lightning.py b/tests/test_third_party/test_against_pytorch_lightning.py
--- a/tests/test_third_party/test_against_pytorch_lightning.py
+++ b/tests/test_third_party/test_against_pytorch_lightning.py
@@ -10,10 +10,12 @@
 from omegaconf import OmegaConf
 
 from hydra_zen import builds, instantiate, just, to_yaml
+from tests import check_identity
 
 pl_objects = [
     pl.Trainer,
     pl.LightningDataModule,
+    pl.LightningDataModule.from_datasets,
     pl.LightningModule,
     pl.accelerators.Accelerator,
     pl.callbacks.Callback,
@@ -34,7 +36,7 @@
 
 @pytest.mark.parametrize("obj", pl_objects)
 def test_just_roundtrip(obj):
-    assert instantiate(just(obj)) is obj
+    assert check_identity(instantiate(just(obj)), obj)
 
 
 @pytest.mark.parametrize("target", pl_objects)