Repository: mit-ll-responsible-ai/hydra-zen (python)

Issue:
Add Python 3.11 support
For parity with Hydra 1.3.0: https://github.com/facebookresearch/hydra/releases/tag/v1.3.0

Pull request description:
Adds support for python 3.11
Closes #353 

Patch:
diff --git a/.github/workflows/tox_run.yml b/.github/workflows/tox_run.yml
index 2f93237c..21d03a76 100644
--- a/.github/workflows/tox_run.yml
+++ b/.github/workflows/tox_run.yml
@@ -19,7 +19,7 @@ jobs:
     strategy:
       max-parallel: 5
       matrix:
-        python-version: [3.7, 3.8, 3.9, "3.10"]
+        python-version: [3.7, 3.8, 3.9, "3.10", 3.11]
       fail-fast: false
 
     steps:
diff --git a/README.md b/README.md
index 58c2fa87..aa61c946 100644
--- a/README.md
+++ b/README.md
@@ -7,7 +7,7 @@
     <img src="https://img.shields.io/pypi/v/hydra-zen.svg" alt="PyPI" />
   </a>
   <a>
-    <img src="https://img.shields.io/badge/python-3.7%20&#8208;%203.10-blue.svg" alt="Python version support" />
+    <img src="https://img.shields.io/badge/python-3.7%20&#8208;%203.11-blue.svg" alt="Python version support" />
   </a>
   <a href="https://github.com/mit-ll-responsible-ai/hydra-zen/actions?query=workflow%3ATests+branch%3Amain">
     <img src="https://github.com/mit-ll-responsible-ai/hydra-zen/workflows/Tests/badge.svg" alt="GitHub Actions" />
diff --git a/docs/source/changes.rst b/docs/source/changes.rst
index 2b7eb752..f948b6e0 100644
--- a/docs/source/changes.rst
+++ b/docs/source/changes.rst
@@ -114,6 +114,7 @@ This enables objects to be stored using a decorator pattern, e.g.
 
 New Features
 ------------
+- hydra-zen now supports Python 3.11
 - Adds the :func:`~hydra_zen.zen` decorator (see :pull:`310`)
 - Adds the :func:`~hydra_zen.wrapper.Zen` decorator-class (see :pull:`310`)
 - Adds the :class:`~hydra_zen.ZenStore` class (see :pull:`331`)
diff --git a/pyproject.toml b/pyproject.toml
index 69638cf2..32c0f19f 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -34,6 +34,7 @@ classifiers = [
     "Programming Language :: Python :: 3.8",
     "Programming Language :: Python :: 3.9",
     "Programming Language :: Python :: 3.10",
+    "Programming Language :: Python :: 3.11",
     "Topic :: Scientific/Engineering",
     "Programming Language :: Python :: 3 :: Only",
 ]
@@ -130,6 +131,7 @@ python =
   3.8: py38
   3.9: py39
   3.10: py310
+  3.11: py311
 
 
 [testenv]
diff --git a/src/hydra_zen/structured_configs/_implementations.py b/src/hydra_zen/structured_configs/_implementations.py
index 7bbde37a..cc7bffe2 100644
--- a/src/hydra_zen/structured_configs/_implementations.py
+++ b/src/hydra_zen/structured_configs/_implementations.py
@@ -184,8 +184,11 @@ def mutable_value(x: _T, *, zen_convert: Optional[ZenConvert] = None) -> _T:
     cast = type(x)  # ensure that we return a copy of the default value
     settings = _utils.merge_settings(zen_convert, _BUILDS_CONVERT_SETTINGS)
     del zen_convert
-    x = sanitize_collection(x, convert_dataclass=settings["dataclass"])
-    return field(default_factory=lambda: cast(x))
+
+    if cast in {list, tuple, dict}:
+        x = sanitize_collection(x, convert_dataclass=settings["dataclass"])
+        return field(default_factory=lambda: cast(x))
+    return field(default_factory=lambda: x)
 
 
 @dataclass_transform()
@@ -797,6 +800,8 @@ def sanitized_field(
     if (
         type_value in _utils.KNOWN_MUTABLE_TYPES
         and type_value in HYDRA_SUPPORTED_PRIMITIVES
+    ) or (
+        is_dataclass(value) and not isinstance(value, type) and value.__hash__ is None
     ):
         if _mutable_default_permitted:
             return cast(
diff --git a/src/hydra_zen/structured_configs/_make_config.py b/src/hydra_zen/structured_configs/_make_config.py
index 05198177..6f7c4d6d 100644
--- a/src/hydra_zen/structured_configs/_make_config.py
+++ b/src/hydra_zen/structured_configs/_make_config.py
@@ -50,7 +50,7 @@ class NOTHING:
         raise TypeError("`NOTHING` cannot be instantiated")
 
 
-@dataclass
+@dataclass(unsafe_hash=True)
 class ZenField:
     """
     ZenField(hint=Any, default=<class 'NOTHING'>, name=<class 'NOTHING'>)


Test patch:
diff --git a/tests/custom_strategies.py b/tests/custom_strategies.py
index f936b8de..2a5e085b 100644
--- a/tests/custom_strategies.py
+++ b/tests/custom_strategies.py
@@ -1,7 +1,6 @@
 # Copyright (c) 2022 Massachusetts Institute of Technology
 # SPDX-License-Identifier: MIT
 import string
-from dataclasses import dataclass
 from typing import (
     Any,
     Deque,
@@ -33,16 +32,6 @@ def _wrapper(obj):
     return obj
 
 
-@dataclass
-class BaseA:
-    ...
-
-
-@dataclass
-class BaseB:
-    ...
-
-
 # strategies for drawing valid inputs to `zen_wrappers`
 single_wrapper_strat = (
     st.just(_wrapper)
@@ -51,6 +40,18 @@ single_wrapper_strat = (
 )
 wrapper_strat = single_wrapper_strat | st.lists(single_wrapper_strat)
 
+slots_strat = st.booleans()
+
+
+def _compat_slots(conf: Dict[str, Any]):
+    # dataclass has some hard rules about a frozen dataclass inheriting
+    # from a non-frozen one anf vice versa. Let's avoid this
+
+    if conf.get("weakref_slot", None) is True:
+        conf["slots"] = True
+    return conf
+
+
 st.register_type_strategy(
     DataclassOptions,
     st.fixed_dictionaries(
@@ -65,10 +66,10 @@ st.register_type_strategy(
             "frozen": st.booleans(),
             "match_args": st.booleans(),
             "kw_only": st.booleans(),
-            "slots": st.booleans(),
-            "weakref_slot": st.booleans(),
+            "slots": st.shared(st.booleans(), key="slot"),
+            "weakref_slot": st.shared(st.booleans(), key="slot"),
         },
-    ),
+    ).map(_compat_slots),
 )
 
 _valid_builds_strats = dict(
@@ -112,14 +113,18 @@ def valid_builds_args(*required: str, excluded: Sequence[str] = ()):
     assert _required <= set(_valid_builds_strats), _required - set(_valid_builds_strats)
     assert _excluded <= set(_valid_builds_strats), _excluded - set(_valid_builds_strats)
 
-    return st.fixed_dictionaries(
-        {k: _valid_builds_strats[k] for k in sorted(_required)},
-        optional={
-            k: v
-            for k, v in _valid_builds_strats.items()
-            if k not in _excluded and k not in _required
-        },
-    ).map(_compat_frozen)
+    return (
+        st.fixed_dictionaries(
+            {k: _valid_builds_strats[k] for k in sorted(_required)},
+            optional={
+                k: v
+                for k, v in _valid_builds_strats.items()
+                if k not in _excluded and k not in _required
+            },
+        )
+        .map(_compat_frozen)
+        .map(_compat_slots)
+    )
 
 
 @st.composite
diff --git a/tests/test_config_value_validation.py b/tests/test_config_value_validation.py
index d09524ef..b06fdd5f 100644
--- a/tests/test_config_value_validation.py
+++ b/tests/test_config_value_validation.py
@@ -6,7 +6,7 @@ from dataclasses import dataclass
 from typing import Any
 
 import pytest
-from hypothesis import HealthCheck, assume, example, given, settings
+from hypothesis import HealthCheck, assume, example, given, note, settings
 from omegaconf import DictConfig, ListConfig, OmegaConf
 from omegaconf.errors import KeyValidationError
 
@@ -21,7 +21,6 @@ from hydra_zen import (
 from hydra_zen._compatibility import ZEN_SUPPORTED_PRIMITIVES
 from hydra_zen.errors import HydraZenUnsupportedPrimitiveError
 from hydra_zen.structured_configs._implementations import HYDRA_SUPPORTED_PRIMITIVES
-from hydra_zen.structured_configs._utils import KNOWN_MUTABLE_TYPES
 from tests import everything_except
 
 
@@ -34,7 +33,7 @@ def f_with_kwargs(*args, **kwargs):
 
 
 def make_hydrated_dataclass(target, a):
-    assume(not isinstance(a, tuple(KNOWN_MUTABLE_TYPES)))
+    assume(a.__hash__ is not None)
 
     @hydrated_dataclass(target)
     class Config:
@@ -44,7 +43,7 @@ def make_hydrated_dataclass(target, a):
 
 
 def make_dataclass(a):
-    assume(not isinstance(a, tuple(KNOWN_MUTABLE_TYPES)))
+    assume(a.__hash__ is not None)
 
     @dataclass
     class C:
@@ -135,6 +134,7 @@ construction_fn_variations = [
 def test_unsupported_config_value_raises_while_making_config(
     config_construction_fn, unsupported
 ):
+    note(f"unsupported: {unsupported}")
     with pytest.raises((HydraZenUnsupportedPrimitiveError, ModuleNotFoundError)):
         config_construction_fn(unsupported)
 
diff --git a/tests/test_dataclass_conversion.py b/tests/test_dataclass_conversion.py
index 484aa900..0c20cd82 100644
--- a/tests/test_dataclass_conversion.py
+++ b/tests/test_dataclass_conversion.py
@@ -1,6 +1,6 @@
 # Copyright (c) 2022 Massachusetts Institute of Technology
 # SPDX-License-Identifier: MIT
-from dataclasses import InitVar, dataclass, field
+from dataclasses import MISSING, InitVar, dataclass, field
 from pathlib import Path
 from typing import Any, Callable, List, Type, TypeVar
 
@@ -18,6 +18,7 @@ from hydra_zen import (
     just,
     make_config,
     make_custom_builds_fn,
+    mutable_value,
 )
 from hydra_zen.errors import HydraZenUnsupportedPrimitiveError
 from hydra_zen.structured_configs._type_guards import is_builds
@@ -53,7 +54,7 @@ def baz(f: float) -> float:
 class Stuff:
     field1: Interface1
     field2: Interface2
-    field3: Nested = Nested(baz)
+    field3: Nested = mutable_value(Nested(baz))
     field4: Any = field(default_factory=lambda: [1, 2, 3])
 
 
@@ -205,6 +206,11 @@ def test_builds_with_initvar_field(dataclass_type):
     ) == dataclass_type(x=1)
 
 
+def _zen_get(obj):
+    x = obj.default.default
+    return x if x is not MISSING else obj.default.default_factory()
+
+
 @pytest.mark.parametrize(
     "dataclass_obj",
     [
@@ -231,9 +237,9 @@ def test_builds_with_initvar_field(dataclass_type):
             dict, x=x
         )().x,
         lambda x: just(x, zen_convert={"dataclass": False}),
-        lambda x: ZenField(
-            default=x, name="x", zen_convert={"dataclass": False}
-        ).default.default,  # type: ignore
+        lambda x: _zen_get(
+            ZenField(default=x, name="x", zen_convert={"dataclass": False})
+        ),
     ],
 )
 def test_no_dataclass_conversion(
@@ -264,10 +270,12 @@ def test_no_dataclass_conversion(
         lambda x: make_custom_builds_fn(zen_convert={"dataclass": True})(dict, x=x)().x,
         lambda x: builds(dict, x=x)().x,
         lambda x: just(x),
-        lambda x: ZenField(
-            default=x,
-            name="x",
-        ).default.default,  # type: ignore
+        lambda x: _zen_get(
+            ZenField(
+                default=x,
+                name="x",
+            )
+        ),
     ],
 )
 def test_yes_dataclass_conversion(
diff --git a/tests/test_py310.py b/tests/test_py310.py
index 7dc12ada..3f4219ee 100644
--- a/tests/test_py310.py
+++ b/tests/test_py310.py
@@ -60,9 +60,6 @@ def func(x):
 @pytest.mark.filterwarnings("ignore:A structured config was supplied for")
 @given(kw=valid_builds_args(), as_inst=..., x=st.sampled_from(["", 1, [1, 2]]))
 def test_equiv_getattr(kw, as_inst: bool, x):
-    conf_fn = (
-        lambda **k: builds(func, x=x, **k) if x else lambda **k: mak(func, x=x, **k)
-    )
     obj = builds(func, x=x, **kw)
 
     if "zen_dataclass" in kw and kw["zen_dataclass"] is not None:
@@ -73,6 +70,7 @@ def test_equiv_getattr(kw, as_inst: bool, x):
             assume(False)
 
         kw["zen_dataclass"]["slots"] = False
+        kw["zen_dataclass"]["weakref_slot"] = False
 
     no_slot_obj = builds(func, x=x, **kw)
 
diff --git a/tests/test_value_conversion.py b/tests/test_value_conversion.py
index 322eef3d..ff8879df 100644
--- a/tests/test_value_conversion.py
+++ b/tests/test_value_conversion.py
@@ -310,10 +310,10 @@ def test_zen_supported_primitives_arent_supported_by_hydra(type_, data: st.DataO
 
     @dataclass
     class C:
-        x: type_ = mutable_value(value) if isinstance(value, (set, dict)) else value
+        x: type_ = mutable_value(value) if value.__hash__ is None else value
 
     with pytest.raises((ValidationError, AssertionError)):
-        Conf = OmegaConf.create(C)
+        Conf = OmegaConf.create(C)  # type: ignore
         assert isinstance(Conf.x, type_)