Repository: facebookresearch/hydra

Issue:
[Bug] [configen] Verify configen output to only valid OmegaConf types
# 🐛 Bug
## Description
Currently, configen will generate configs with types which may not be supported by hydra.

Example:
```
@dataclass
class SGDConf:
    _target_: str = "torch.optim.SGD"
    params: Any = MISSING
    lr: Any = <required parameter>
    momentum: Any = 0
    dampening: Any = 0
    weight_decay: Any = 0
    nesterov: Any = False
```

where `<required parameter>` is a string.

Ideally, this line should be: `lr: Any = MISSING`



Discussion:

Resolving patch:
diff --git a/tools/configen/configen/configen.py b/tools/configen/configen/configen.py
--- a/tools/configen/configen/configen.py
+++ b/tools/configen/configen/configen.py
@@ -5,13 +5,18 @@
 from dataclasses import dataclass
 from enum import Enum
 from pathlib import Path
-from typing import Any, Dict, List, Optional, Type
+from typing import Any, Dict, List, Optional, Set, Type
 
 from jinja2 import Environment, PackageLoader, Template
 from omegaconf import OmegaConf, ValidationError
 from omegaconf._utils import (
     _is_union,
     _resolve_optional,
+    get_dict_key_value_types,
+    get_list_element_type,
+    is_dict_annotation,
+    is_list_annotation,
+    is_primitive_type,
     is_structured_config,
     type_str,
 )
@@ -72,6 +77,20 @@ class ClassInfo:
 
 def _is_passthrough(type_: Type[Any]) -> bool:
     type_ = _resolve_optional(type_)[1]
+    if type_ is type(None):  # noqa
+        return False
+    try:
+        if is_list_annotation(type_):
+            lt = get_list_element_type(type_)
+            return _is_passthrough(lt)
+        if is_dict_annotation(type_):
+            kvt = get_dict_key_value_types(type_)
+            if not issubclass(kvt[0], (str, Enum)):
+                return True
+            return _is_passthrough(kvt[1])
+    except ValidationError:
+        return True
+
     if type_ is Any or issubclass(type_, (int, float, str, bool, Enum)):
         return False
     if is_structured_config(type_):
@@ -86,8 +105,48 @@ def _is_passthrough(type_: Type[Any]) -> bool:
     return True
 
 
+def collect_imports(imports: Set[Type], type_: Type) -> None:
+    if is_list_annotation(type_):
+        collect_imports(imports, get_list_element_type(type_))
+        type_ = List
+    elif is_dict_annotation(type_):
+        kvt = get_dict_key_value_types(type_)
+        collect_imports(imports, kvt[0])
+        collect_imports(imports, kvt[1])
+        type_ = Dict
+    else:
+        is_optional = _resolve_optional(type_)[0]
+        if is_optional and type_ is not Any:
+            type_ = Optional
+    imports.add(type_)
+
+
+def convert_imports(imports: Set[Type]) -> List[str]:
+    res = []
+    for t in imports:
+        s = None
+        if t is Any:
+            classname = "Any"
+        elif t is Optional:
+            classname = "Optional"
+        elif t is List:
+            classname = "List"
+        elif t is Dict:
+            classname = "Dict"
+        else:
+            classname = t.__name__
+
+        if not is_primitive_type(t) or issubclass(t, Enum):
+            s = f"from {t.__module__} import {classname}"
+
+        if s is not None:
+            res.append(s)
+    return sorted(res)
+
+
 def generate_module(cfg: ConfigenConf, module: ModuleConf) -> str:
     classes_map: Dict[str, ClassInfo] = {}
+    imports = set()
     for class_name in module.classes:
         full_name = f"{module.name}.{class_name}"
         cls = hydra.utils.get_class(full_name)
@@ -96,18 +155,41 @@ def generate_module(cfg: ConfigenConf, module: ModuleConf) -> str:
         for name, p in sig.parameters.items():
             type_ = p.annotation
             default_ = p.default
-            if type_ == str and default_ is not inspect._empty:
-                default_ = f'"{default_}"'
 
-            optional = _resolve_optional(type_)[0]
-            if type_ == sig.empty or (not optional and _is_union(type_)):
+            is_optional = False
+            if type_ == sig.empty:
+                type_ = Any
+            else:
+                resolved_optional = _resolve_optional(type_)
+                is_optional = resolved_optional[0]
+
+            # Unions are not supported (Except Optional)
+            if not is_optional and _is_union(type_):
                 type_ = Any
+
+            passthrough_value = False
+            if default_ != sig.empty:
+                if type_ == str:
+                    default_ = f'"{default_}"'
+                elif is_list_annotation(type_):
+                    default_ = f"field(default_factory={p.default})"
+                elif is_dict_annotation(type_):
+                    default_ = f"field(default_factory={p.default})"
+
+                passthrough_value = _is_passthrough(type(default_))
+
+            # fields that are incompatible with the config are flagged as passthrough and are added as a comment
+            passthrough = _is_passthrough(type_) or passthrough_value
+
+            if not passthrough:
+                collect_imports(imports, type_)
+
             params.append(
                 Parameter(
                     name=name,
                     type_str=type_str(type_),
                     default=default_,
-                    passthrough=_is_passthrough(type_),
+                    passthrough=passthrough,
                 )
             )
         classes_map[class_name] = ClassInfo(
@@ -119,6 +201,7 @@ def generate_module(cfg: ConfigenConf, module: ModuleConf) -> str:
 
     template = jinja_env.get_template("module.j2")
     return template.render(
+        imports=convert_imports(imports),
         classes=module.classes,
         classes_map=classes_map,
         header=cfg.header,
diff --git a/tools/configen/setup.py b/tools/configen/setup.py
--- a/tools/configen/setup.py
+++ b/tools/configen/setup.py
@@ -6,7 +6,7 @@
 
 setup(
     name="hydra-configen",
-    version="0.9.0-dev3",
+    version="0.9.0-dev4",
     packages=find_packages(include=["configen"]),
     entry_points={"console_scripts": ["configen = configen.configen:main"]},
     author="Omry Yadan",


Test patch:
diff --git a/tools/configen/tests/test_generate.py b/tools/configen/tests/test_generate.py
--- a/tools/configen/tests/test_generate.py
+++ b/tools/configen/tests/test_generate.py
@@ -36,25 +36,22 @@
 
 
 @pytest.mark.parametrize(
-    "module_name,class_name",
+    "class_name",
     [
-        pytest.param("tests.test_modules", "Empty", id="Empty"),
-        pytest.param("tests.test_modules", "UntypedArg", id="UntypedArg"),
-        pytest.param("tests.test_modules", "IntArg", id="IntArg"),
-        pytest.param("tests.test_modules", "UnionArg", id="UnionArg"),
-        pytest.param(
-            "tests.test_modules", "WithLibraryClassArg", id="WithLibraryClassArg"
-        ),
-        pytest.param(
-            "tests.test_modules",
-            "IncompatibleDataclassArg",
-            id="IncompatibleDataclassArg",
-        ),
-        pytest.param("tests.test_modules", "WithStringDefault", id="WithStringDefault"),
+        "Empty",
+        "UntypedArg",
+        "IntArg",
+        "UnionArg",
+        "WithLibraryClassArg",
+        "IncompatibleDataclassArg",
+        "WithStringDefault",
+        "ListValues",
+        "DictValues",
+        "PeskySentinelUsage",
     ],
 )
-def test_generated_code(module_name: str, class_name: str) -> None:
-
+def test_generated_code(class_name: str) -> None:
+    module_name = "tests.test_modules"
     expected_file = (
         Path(module_name.replace(".", "/")) / "expected" / f"{class_name}.py"
     )
diff --git a/tools/configen/tests/test_modules/__init__.py b/tools/configen/tests/test_modules/__init__.py
--- a/tools/configen/tests/test_modules/__init__.py
+++ b/tools/configen/tests/test_modules/__init__.py
@@ -1,6 +1,21 @@
 # Copyright (c) Facebook, Inc. and its affiliates. All Rights Reserved
 from dataclasses import dataclass
-from typing import Optional, Union
+from enum import Enum
+from typing import Dict, List, Optional, Union
+
+from omegaconf import MISSING
+
+
+class Color(Enum):
+    RED = 0
+    GREEN = 1
+    BLUE = 2
+
+
+@dataclass
+class User:
+    name: str = MISSING
+    age: int = MISSING
 
 
 class LibraryClass:
@@ -56,3 +71,40 @@ def __init__(
         none_str: Optional[str] = None,
     ):
         ...
+
+
+class ListValues:
+    def __init__(
+        self,
+        lst: List[str],
+        enum_lst: List[Color],
+        passthrough_list: List[LibraryClass],
+        dataclass_val: List[User],
+        def_value: List[str] = [],
+    ):
+        ...
+
+
+class DictValues:
+    def __init__(
+        self,
+        dct: Dict[str, str],
+        enum_key: Dict[Color, str],
+        dataclass_val: Dict[str, User],
+        passthrough_dict: Dict[str, LibraryClass],
+        def_value: Dict[str, str] = {},
+    ):
+        ...
+
+
+class PeskySentinel(object):
+    def __repr__(self):
+        return "<I am a pesky sentinel>"
+
+
+pesky = PeskySentinel()
+
+
+class PeskySentinelUsage:
+    def __init__(self, foo=pesky):
+        ...
diff --git a/tools/configen/tests/test_modules/expected/DictValues.py b/tools/configen/tests/test_modules/expected/DictValues.py
new file mode 100644
--- /dev/null
+++ b/tools/configen/tests/test_modules/expected/DictValues.py
@@ -0,0 +1,22 @@
+# Copyright (c) Facebook, Inc. and its affiliates. All Rights Reserved
+# Generated by configen, do not edit.
+# See https://github.com/facebookresearch/hydra/tree/master/tools/configen
+# fmt: off
+# isort:skip_file
+# flake8: noqa
+
+from dataclasses import dataclass, field
+from omegaconf import MISSING
+from tests.test_modules import Color
+from tests.test_modules import User
+from typing import Dict
+
+
+@dataclass
+class DictValuesConf:
+    _target_: str = "tests.test_modules.DictValues"
+    dct: Dict[str, str] = MISSING
+    enum_key: Dict[Color, str] = MISSING
+    dataclass_val: Dict[str, User] = MISSING
+    # [passthrough] passthrough_dict: Dict[str, LibraryClass]
+    def_value: Dict[str, str] = field(default_factory={})
diff --git a/tools/configen/tests/test_modules/expected/Empty.py b/tools/configen/tests/test_modules/expected/Empty.py
--- a/tools/configen/tests/test_modules/expected/Empty.py
+++ b/tools/configen/tests/test_modules/expected/Empty.py
@@ -5,9 +5,7 @@
 # isort:skip_file
 # flake8: noqa
 
-from dataclasses import dataclass
-from typing import *
-
+from dataclasses import dataclass, field
 from omegaconf import MISSING
 
 
diff --git a/tools/configen/tests/test_modules/expected/IncompatibleDataclassArg.py b/tools/configen/tests/test_modules/expected/IncompatibleDataclassArg.py
--- a/tools/configen/tests/test_modules/expected/IncompatibleDataclassArg.py
+++ b/tools/configen/tests/test_modules/expected/IncompatibleDataclassArg.py
@@ -5,9 +5,7 @@
 # isort:skip_file
 # flake8: noqa
 
-from dataclasses import dataclass
-from typing import *
-
+from dataclasses import dataclass, field
 from omegaconf import MISSING
 
 
diff --git a/tools/configen/tests/test_modules/expected/IntArg.py b/tools/configen/tests/test_modules/expected/IntArg.py
--- a/tools/configen/tests/test_modules/expected/IntArg.py
+++ b/tools/configen/tests/test_modules/expected/IntArg.py
@@ -5,9 +5,7 @@
 # isort:skip_file
 # flake8: noqa
 
-from dataclasses import dataclass
-from typing import *
-
+from dataclasses import dataclass, field
 from omegaconf import MISSING
 
 
diff --git a/tools/configen/tests/test_modules/expected/ListValues.py b/tools/configen/tests/test_modules/expected/ListValues.py
new file mode 100644
--- /dev/null
+++ b/tools/configen/tests/test_modules/expected/ListValues.py
@@ -0,0 +1,22 @@
+# Copyright (c) Facebook, Inc. and its affiliates. All Rights Reserved
+# Generated by configen, do not edit.
+# See https://github.com/facebookresearch/hydra/tree/master/tools/configen
+# fmt: off
+# isort:skip_file
+# flake8: noqa
+
+from dataclasses import dataclass, field
+from omegaconf import MISSING
+from tests.test_modules import Color
+from tests.test_modules import User
+from typing import List
+
+
+@dataclass
+class ListValuesConf:
+    _target_: str = "tests.test_modules.ListValues"
+    lst: List[str] = MISSING
+    enum_lst: List[Color] = MISSING
+    # [passthrough] passthrough_list: List[LibraryClass]
+    dataclass_val: List[User] = MISSING
+    def_value: List[str] = field(default_factory=[])
diff --git a/tools/configen/tests/test_modules/expected/PeskySentinelUsage.py b/tools/configen/tests/test_modules/expected/PeskySentinelUsage.py
new file mode 100644
--- /dev/null
+++ b/tools/configen/tests/test_modules/expected/PeskySentinelUsage.py
@@ -0,0 +1,15 @@
+# Copyright (c) Facebook, Inc. and its affiliates. All Rights Reserved
+# Generated by configen, do not edit.
+# See https://github.com/facebookresearch/hydra/tree/master/tools/configen
+# fmt: off
+# isort:skip_file
+# flake8: noqa
+
+from dataclasses import dataclass, field
+from omegaconf import MISSING
+
+
+@dataclass
+class PeskySentinelUsageConf:
+    _target_: str = "tests.test_modules.PeskySentinelUsage"
+    # [passthrough] foo: Any = <I am a pesky sentinel>
diff --git a/tools/configen/tests/test_modules/expected/UnionArg.py b/tools/configen/tests/test_modules/expected/UnionArg.py
--- a/tools/configen/tests/test_modules/expected/UnionArg.py
+++ b/tools/configen/tests/test_modules/expected/UnionArg.py
@@ -5,10 +5,9 @@
 # isort:skip_file
 # flake8: noqa
 
-from dataclasses import dataclass
-from typing import *
-
+from dataclasses import dataclass, field
 from omegaconf import MISSING
+from typing import Any
 
 
 @dataclass
diff --git a/tools/configen/tests/test_modules/expected/UntypedArg.py b/tools/configen/tests/test_modules/expected/UntypedArg.py
--- a/tools/configen/tests/test_modules/expected/UntypedArg.py
+++ b/tools/configen/tests/test_modules/expected/UntypedArg.py
@@ -5,10 +5,9 @@
 # isort:skip_file
 # flake8: noqa
 
-from dataclasses import dataclass
-from typing import *
-
+from dataclasses import dataclass, field
 from omegaconf import MISSING
+from typing import Any
 
 
 @dataclass
diff --git a/tools/configen/tests/test_modules/expected/WithLibraryClassArg.py b/tools/configen/tests/test_modules/expected/WithLibraryClassArg.py
--- a/tools/configen/tests/test_modules/expected/WithLibraryClassArg.py
+++ b/tools/configen/tests/test_modules/expected/WithLibraryClassArg.py
@@ -5,9 +5,7 @@
 # isort:skip_file
 # flake8: noqa
 
-from dataclasses import dataclass
-from typing import *
-
+from dataclasses import dataclass, field
 from omegaconf import MISSING
 
 
diff --git a/tools/configen/tests/test_modules/expected/WithStringDefault.py b/tools/configen/tests/test_modules/expected/WithStringDefault.py
--- a/tools/configen/tests/test_modules/expected/WithStringDefault.py
+++ b/tools/configen/tests/test_modules/expected/WithStringDefault.py
@@ -5,10 +5,9 @@
 # isort:skip_file
 # flake8: noqa
 
-from dataclasses import dataclass
-from typing import *
-
+from dataclasses import dataclass, field
 from omegaconf import MISSING
+from typing import Optional
 
 
 @dataclass