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

Issue:
Specialized support for `HydraConf` in `ZenStore`?
Inspired by #394 , it might be nice for `store`/`ZenStore` to have specialized support for instances/subclasses of `HydraConf` so that

```python
store(HydraConf(job=JobConf(chdir=True)))
```

is equivalent to the long-form

```python
store(HydraConf(job=JobConf(chdir=True)), name="config", group="hydra", provider="hydra_zen")
```

i.e. we auto-populate the name/group/provider to be the obvious entries if the user doesn't specify it.

Furthermore, we can special case `store.add_to_hydra_store()` to automatically overwrite the default Hydra entry (but only if it is the entry provided by Hydra itself) since this is the obvious desired behavior if the user is storing their own `HydraConf`.

This would almost certainly be the only special case we would want to specify for `store`. There are some tradeoffs here between convenience and explicitness, and perhaps this leans towards making things a bit too magical for users. It is worth discussing the pros and cons.



Pull request description:
Infer name/group for storing HydraConf instances/subclasses
Closes #395 



Patch:
diff --git a/docs/requirements.txt b/docs/requirements.txt
index e1e5ce86..08f5e6bf 100644
--- a/docs/requirements.txt
+++ b/docs/requirements.txt
@@ -5,5 +5,5 @@ sphinx-copybutton==0.5.1
 sphinx-design==0.3.0
 sphinx-codeautolink==0.13.1
 # third-party utilities
-beartype==0.12.0
+beartype==0.11.0
 pydantic==1.10.4
diff --git a/src/hydra_zen/wrapper/_implementations.py b/src/hydra_zen/wrapper/_implementations.py
index 55261530..af7a4559 100644
--- a/src/hydra_zen/wrapper/_implementations.py
+++ b/src/hydra_zen/wrapper/_implementations.py
@@ -30,6 +30,7 @@ from typing import (
 )
 
 import hydra
+from hydra.conf import HydraConf
 from hydra.core.config_store import ConfigStore
 from hydra.utils import instantiate
 from omegaconf import DictConfig, ListConfig, OmegaConf
@@ -796,6 +797,10 @@ def default_to_config(
     """
     if is_dataclass(target):
         if isinstance(target, type):
+            if issubclass(target, HydraConf):
+                # don't auto-config HydraConf
+                return target
+
             if not kw and get_obj_path(target).startswith("types."):
                 # handles dataclasses returned by make_config()
                 return target
@@ -909,6 +914,28 @@ class ZenStore:
            deferred_hydra_store=True,
        )
 
+    Notes
+    -----
+    Special support is provided for overriding Hydra's configuration; the name and
+    group of the store entry is inferred to be 'config' and 'hydra', respectively,
+    when an instance/subclass of `HydraConf` is being stored. E.g., specifying
+
+    .. code-block:: python
+
+       from hydra.conf import HydraConf, JobConf
+       from hydra_zen import store
+
+       store(HydraConf(job=JobConf(chdir=True)))
+
+    is equivalent to writing the following manually
+
+    .. code-block:: python
+
+       store(HydraConf(job=JobConf(chdir=True)), name="config", group="hydra", provider="hydra_zen")
+
+    Additionally, overwriting the store entry for `HydraConf` will not raise an error
+    even if `ZenStore(overwrite_ok=False)` is specified.
+
     Examples
     --------
     >>> from hydra_zen import to_yaml, store, ZenStore
@@ -1005,6 +1032,19 @@ class ZenStore:
     ... def func(a: int, b: int):
     ...     return a - b
 
+    Each application of `@store` utilizes the store's auto-config capability
+    to create and store a config inline. I.e. the above snippet is equivalent to
+
+    >>> from hydra_zen import builds
+    >>>
+    >>> store(builds(func, a=1, b=22), name="func1")
+    >>> store(builds(func, a=-10,
+    ...              populate_full_signature=True
+    ...              ),
+    ...       name="func2",
+    ... )
+
+
     >>> func(10, 3)  # the decorated function is left unchanged
     7
     >>> pyaml(store[None, "func1"])
@@ -1287,6 +1327,20 @@ class ZenStore:
             package = kw.get("package", self._defaults["package"])
             provider = kw.get("provider", self._defaults["provider"])
 
+            if (
+                isinstance(__target, HydraConf)
+                or isinstance(__target, type)
+                and issubclass(__target, HydraConf)
+            ):
+                # User is re-configuring Hydra's config; we provide "smart" defaults
+                # for the entry's name, group, and package
+                if "name" not in kw and "group" not in kw:  # pragma: no branch
+                    # only apply when neither name nor group are specified
+                    name = "config"
+                    group = "hydra"
+                    if "provider" not in kw:  # pragma: no branch
+                        provider = "hydra_zen"
+
             _name: NodeName = name(__target) if callable(name) else name
             if not isinstance(_name, str):
                 raise TypeError(f"`name` must be a string, got {_name}")
@@ -1535,19 +1589,30 @@ class ZenStore:
         >>> store2.add_to_hydra_store(overwrite_ok=True)  # successfully overwrites entry
 
         """
-
+        _store = ConfigStore.instance().store
         while self._queue:
             entry = _resolve_node(self._queue.popleft(), copy=False)
             if (
-                overwrite_ok is False
-                or (overwrite_ok is None and not self._overwrite_ok)
-            ) and self._exists_in_hydra_store(name=entry["name"], group=entry["group"]):
+                (
+                    overwrite_ok is False
+                    or (overwrite_ok is None and not self._overwrite_ok)
+                )
+                and self._exists_in_hydra_store(
+                    name=entry["name"], group=entry["group"]
+                )
+                # It is okay if we are overwriting Hydra's default store
+                and not (
+                    (entry["name"], entry["group"]) == ("config", "hydra")
+                    and ConfigStore.instance().repo["hydra"]["config.yaml"].provider
+                    == "hydra"
+                )
+            ):
                 raise ValueError(
                     f"(name={entry['name']} group={entry['group']}): "
                     f"Hydra config store entry already exists. Specify "
                     f"`overwrite_ok=True` to enable replacing config store entries"
                 )
-            ConfigStore.instance().store(**entry)
+            _store(**entry)
 
     def _exists_in_hydra_store(
         self,


Test patch:
diff --git a/tests/example_app/change_hydra_config.py b/tests/example_app/change_hydra_config.py
new file mode 100644
index 00000000..627ed03a
--- /dev/null
+++ b/tests/example_app/change_hydra_config.py
@@ -0,0 +1,25 @@
+# Copyright (c) 2023 Massachusetts Institute of Technology
+# SPDX-License-Identifier: MIT
+
+from datetime import datetime
+
+from hydra.conf import HydraConf, JobConf
+
+from hydra_zen import store, zen
+
+store(HydraConf(job=JobConf(chdir=True)))
+
+
+@store
+def task():
+    # Used to test that configuring Hydra to change working dir to time-stamped
+    # output dir works as-expected
+    from pathlib import Path
+
+    path = Path.cwd()
+    assert path.parent.name == datetime.today().strftime("%Y-%m-%d")
+
+
+if __name__ == "__main__":
+    store.add_to_hydra_store()
+    zen(task).hydra_main(config_name="task", config_path=None)
diff --git a/tests/test_signature_parsing.py b/tests/test_signature_parsing.py
index 8478e5b1..b46a9b1a 100644
--- a/tests/test_signature_parsing.py
+++ b/tests/test_signature_parsing.py
@@ -325,9 +325,9 @@ def expects_int(x: int) -> int:
 @pytest.mark.parametrize(
     "builds_as_default",
     [
-        builds(returns_int),  # type
-        builds(returns_int)(),  # instance
-    ],
+        builds(returns_int),
+        builds(returns_int)(),
+    ],  # type  # instance
 )
 @pytest.mark.parametrize("hydra_recursive", [True, None])
 def test_setting_default_with_builds_widens_type(builds_as_default, hydra_recursive):
diff --git a/tests/test_store.py b/tests/test_store.py
index 62088f9a..699788a4 100644
--- a/tests/test_store.py
+++ b/tests/test_store.py
@@ -1,6 +1,8 @@
 # Copyright (c) 2023 Massachusetts Institute of Technology
 # SPDX-License-Identifier: MIT
+import os
 import re
+import sys
 from contextlib import contextmanager
 from copy import deepcopy
 from dataclasses import dataclass
@@ -9,6 +11,7 @@ from typing import Any, Callable, Hashable, Optional
 
 import hypothesis.strategies as st
 import pytest
+from hydra.conf import HydraConf
 from hydra.core.config_store import ConfigStore
 from hypothesis import assume, given, note, settings
 from omegaconf import DictConfig, ListConfig
@@ -21,7 +24,7 @@ from hydra_zen import (
     make_config,
     store as default_store,
 )
-from hydra_zen._compatibility import HYDRA_SUPPORTS_LIST_INSTANTIATION
+from hydra_zen._compatibility import HYDRA_SUPPORTS_LIST_INSTANTIATION, HYDRA_VERSION
 from tests.custom_strategies import new_stores, store_entries
 
 cs = ConfigStore().instance()
@@ -724,3 +727,65 @@ def test_entry_access_cannot_mutate_store(store: ZenStore, getter):
     entry["name"] = 2222
     new_entries = tuple(d.copy() for d in store)
     assert all(e in new_entries for e in entries)
+
+
+class CustomHydraConf(HydraConf):
+    ...
+
+
+@pytest.mark.parametrize("conf", [CustomHydraConf, HydraConf()])
+@pytest.mark.parametrize("deferred", [True, False])
+@pytest.mark.usefixtures("clean_store")
+def test_auto_support_for_HydraConf(conf: HydraConf, deferred: bool):
+    with clean_store():
+        st1 = ZenStore(deferred_hydra_store=deferred)
+        st2 = ZenStore(deferred_hydra_store=True)
+        st1(conf)
+        st1.add_to_hydra_store()
+        assert st1["hydra", "config"] is conf
+
+        with pytest.raises(
+            ValueError,
+            match=re.escape(r"(name=config group=hydra):"),
+        ):
+            # Attempting to overwrite HydraConf within the same
+            # store should fail.
+            st1(conf)
+
+        st2(conf)
+        with pytest.raises(ValueError):
+            st2.add_to_hydra_store()
+
+
+@pytest.mark.skipif(
+    sys.platform.startswith("win") and bool(os.environ.get("CI")),
+    reason="Things are weird on GitHub Actions and Windows",
+)
+@pytest.mark.skipif(
+    HYDRA_VERSION < (1, 2, 0),
+    reason="HydraConf(job=Job(chdir=...)) introduced in Hydra 1.2.0",
+)
+@pytest.mark.parametrize(
+    "inp",
+    [
+        None,
+        pytest.param(
+            "hydra.job.chdir=False",
+            marks=pytest.mark.xfail(
+                reason="Hydra should not change directory in this case"
+            ),
+        ),
+    ],
+)
+@pytest.mark.usefixtures("clean_store")
+@pytest.mark.usefixtures("cleandir")
+def test_configure_hydra_chdir(inp: str):
+    import subprocess
+    from pathlib import Path
+
+    path = (Path(__file__).parent / "example_app" / "change_hydra_config.py").absolute()
+
+    cli = ["python", path]
+    if inp:
+        cli.append(inp)
+    subprocess.run(cli).check_returncode()