Repository: mit-ll-responsible-ai/hydra-zen
Pull request #273 (2022-05-06 20:17:51)

Adds `version_base` to `launch`
This mirrors functionality that will be exposed by `hydra.run` and `hydra.initialize` starting with Hydra 1.2.0:

- If the `version_base` parameter is not specified, Hydra 1.x will use defaults compatible with version 1.1. Also in this case, a warning is issued to indicate an explicit version_base is preferred.
- If the `version_base` parameter is `None`, then the defaults are chosen for the current minor Hydra version. For example for Hydra 1.2, then would imply `config_path=None` and `hydra.job.chdir=False`.
- If the `version_base` parameter is an explicit version string like "1.1", then the defaults appropriate to that version are used.

Our tests will specify `version_base` in accordance with the local version of Hydra, unless otherwise appropriate, via a pytest fixture.


Patch:
diff --git a/docs/source/changes.rst b/docs/source/changes.rst
index c6219f6b..c1913f8b 100644
--- a/docs/source/changes.rst
+++ b/docs/source/changes.rst
@@ -60,6 +60,10 @@ will no longer auto-broaden nested container types when ``OmegaConf v2.2.0+`` is
 installed. (See :pull:`261`)
 
 
+Hydra ``v1.2.0`` is introducing a ``version_base`` parameter that can control default behaviors in ``hydra.run`` and ``hydra.initialize``.
+Correspondingly, ``version_base`` is now exposed via `~hydra_zen.launch`. See :pull:`273` for more details.
+
+
 .. _0p7p0-deprecations:
 
 Deprecations
diff --git a/src/hydra_zen/_launch.py b/src/hydra_zen/_launch.py
index 25a54235..60c4c461 100644
--- a/src/hydra_zen/_launch.py
+++ b/src/hydra_zen/_launch.py
@@ -17,6 +17,10 @@ from hydra_zen._hydra_overloads import instantiate
 from hydra_zen.typing._implementations import DataClass
 
 
+class _NotSet:  # pragma: no cover
+    pass
+
+
 def _store_config(
     cfg: Union[DataClass, Type[DataClass], DictConfig, ListConfig, Mapping[Any, Any]],
     config_name: str = "hydra_launch",
@@ -54,11 +58,12 @@ def launch(
     config: Union[DataClass, Type[DataClass], Mapping[str, Any]],
     task_function: Callable[[DictConfig], Any],
     overrides: Optional[List[str]] = None,
+    multirun: bool = False,
+    version_base: Optional[Union[str, Type[_NotSet]]] = _NotSet,
+    to_dictconfig: bool = False,
     config_name: str = "zen_launch",
     job_name: str = "zen_launch",
     with_log_configuration: bool = True,
-    multirun: bool = False,
-    to_dictconfig: bool = False,
 ) -> Union[JobReturn, Any]:
     r"""Launch a Hydra job using a Python-based interface.
 
@@ -84,6 +89,19 @@ def launch(
         If provided, sets/overrides values in ``config``. See [1]_ and [2]_
         for a detailed discussion of the "grammar" supported by ``overrides``.
 
+    multirun : bool (default: False)
+        Launch a Hydra multi-run ([3]_).
+
+    version_base : Optional[str], optional (default=_NotSet)
+        Available starting with Hydra 1.2.0.
+        - If the `version_base parameter` is not specified, Hydra 1.x will use defaults compatible with version 1.1. Also in this case, a warning is issued to indicate an explicit version_base is preferred.
+        - If the `version_base parameter` is `None`, then the defaults are chosen for the current minor Hydra version. For example for Hydra 1.2, then would imply `config_path=None` and `hydra.job.chdir=False`.
+        - If the `version_base` parameter is an explicit version string like "1.1", then the defaults appropriate to that version are used.
+
+    to_dictconfig: bool (default: False)
+        If ``True``, convert a ``dataclasses.dataclass`` to a ``omegaconf.DictConfig``. Note, this
+        will remove Hydra's cabability for validation with structured configurations.
+
     config_name : str (default: "zen_launch")
         Name of the stored configuration in Hydra's ConfigStore API.
 
@@ -92,13 +110,6 @@ def launch(
     with_log_configuration : bool (default: True)
         If ``True``, enables the configuration of the logging subsystem from the loaded config.
 
-    multirun : bool (default: False)
-        Launch a Hydra multi-run ([3]_).
-
-    to_dictconfig: bool (default: False)
-        If ``True``, convert a ``dataclasses.dataclass`` to a ``omegaconf.DictConfig``. Note, this
-        will remove Hydra's cabability for validation with structured configurations.
-
     Returns
     -------
     result : JobReturn | Any
@@ -221,7 +232,11 @@ def launch(
         config_name = _store_config(config, config_name)
 
     # Initializes Hydra and add the config_path to the config search path
-    with initialize(config_path=None, job_name=job_name):
+    with initialize(
+        config_path=None,
+        job_name=job_name,
+        **({} if version_base is _NotSet else {"version_base": version_base})
+    ):
 
         # taken from hydra.compose with support for MULTIRUN
         gh = GlobalHydra.instance()


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
index ddb88ccc..147b77fb 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -1,18 +1,19 @@
 # Copyright (c) 2022 Massachusetts Institute of Technology
 # SPDX-License-Identifier: MIT
 
-
 import logging
 import os
 import sys
 import tempfile
-from typing import Iterable
+from typing import Dict, Iterable, Optional
 
 import hypothesis.strategies as st
 import pkg_resources
 import pytest
 from omegaconf import DictConfig, ListConfig
 
+from hydra_zen._compatibility import HYDRA_VERSION
+
 # Skip collection of tests that don't work on the current version of Python.
 collect_ignore_glob = []
 
@@ -55,6 +56,17 @@ def cleandir() -> Iterable[str]:
         logging.shutdown()
 
 
+@pytest.fixture()
+def version_base() -> Dict[str, Optional[str]]:
+    """Return version_base according to local version, or empty dict for versions
+    preceding version_base"""
+    return (
+        {"version_base": ".".join(str(i) for i in HYDRA_VERSION)}
+        if HYDRA_VERSION >= (1, 2, 0)
+        else {}
+    )
+
+
 pytest_plugins = "pytester"
 
 st.register_type_strategy(ListConfig, st.lists(st.integers()).map(ListConfig))
diff --git a/tests/test_defaults_list.py b/tests/test_defaults_list.py
index 5e654c85..841712a4 100644
--- a/tests/test_defaults_list.py
+++ b/tests/test_defaults_list.py
@@ -13,19 +13,19 @@ from hydra_zen import builds, instantiate, launch, make_config
 from hydra_zen.errors import HydraZenValidationError
 
 
-def test_hydra_defaults_work_builds():
+def test_hydra_defaults_work_builds(version_base):
     config_store = ConfigStore.instance()
     config_store.store(group="x", name="a", node=builds(int, 10))
     Conf = builds(dict, x=None, y="hi", hydra_defaults=["_self_", {"x": "a"}])
-    job = launch(Conf, instantiate)
+    job = launch(Conf, instantiate, **version_base)
     assert job.return_value == {"x": 10, "y": "hi"}
 
 
-def test_hydra_defaults_work_make_config():
+def test_hydra_defaults_work_make_config(version_base):
     config_store = ConfigStore.instance()
     config_store.store(group="x", name="a", node=builds(int, 10))
     Conf = make_config(x=None, y="hi", hydra_defaults=["_self_", {"x": "a"}])
-    job = launch(Conf, instantiate)
+    job = launch(Conf, instantiate, **version_base)
     assert job.return_value == {"x": 10, "y": "hi"}
 
 
diff --git a/tests/test_launch/test_callbacks.py b/tests/test_launch/test_callbacks.py
index e46d1b76..f7b43c5d 100644
--- a/tests/test_launch/test_callbacks.py
+++ b/tests/test_launch/test_callbacks.py
@@ -73,7 +73,7 @@ def tracker(x=CustomCallback):
 
 @pytest.mark.usefixtures("cleandir")
 @pytest.mark.parametrize("multirun", [False, True])
-def test_hydra_run_with_callback(multirun):
+def test_hydra_run_with_callback(multirun, version_base: dict):
     # Tests that callback methods are called during appropriate
     # stages
     try:
@@ -86,6 +86,7 @@ def test_hydra_run_with_callback(multirun):
             task_function=instantiate,
             overrides=["hydra/callbacks=test_callback"],
             multirun=multirun,
+            **version_base,
         )
 
         if multirun:
diff --git a/tests/test_launch/test_implementations.py b/tests/test_launch/test_implementations.py
index 3adb5a8d..105ccc26 100644
--- a/tests/test_launch/test_implementations.py
+++ b/tests/test_launch/test_implementations.py
@@ -2,6 +2,7 @@
 # SPDX-License-Identifier: MIT
 
 from pathlib import Path
+from typing import Optional
 
 import pytest
 from hydra.core.config_store import ConfigStore
@@ -11,6 +12,7 @@ from hydra.plugins.sweeper import Sweeper
 from omegaconf.omegaconf import OmegaConf
 
 from hydra_zen import builds, instantiate, launch, make_config
+from hydra_zen._compatibility import HYDRA_VERSION
 from hydra_zen._launch import _store_config
 
 try:
@@ -49,8 +51,8 @@ def test_store_config(cfg):
 @pytest.mark.usefixtures("cleandir")
 @pytest.mark.parametrize("cfg", CONFIG_TYPE_EXAMPLES)
 @pytest.mark.parametrize("multirun", [False, True])
-def test_launch_config_type(cfg, multirun):
-    job = launch(cfg, task_function=instantiate, multirun=multirun)
+def test_launch_config_type(cfg, multirun, version_base):
+    job = launch(cfg, task_function=instantiate, multirun=multirun, **version_base)
     if isinstance(job, list):
         job = job[0][0]
 
@@ -64,13 +66,13 @@ def test_launch_config_type(cfg, multirun):
 @pytest.mark.usefixtures("cleandir")
 @pytest.mark.parametrize("cfg", DATACLASS_CONFIG_TYPE_EXAMPLES)
 @pytest.mark.parametrize("to_dictconfig", [True, False])
-def test_launch_to_dictconfig(cfg, to_dictconfig):
+def test_launch_to_dictconfig(cfg, to_dictconfig, version_base):
     pre_num_fields = len(dataclasses.fields(cfg))
 
     def task_fn(cfg):
         _ = cloudpickle.loads(cloudpickle.dumps(cfg))
 
-    launch(cfg, task_function=task_fn, to_dictconfig=to_dictconfig)
+    launch(cfg, task_function=task_fn, to_dictconfig=to_dictconfig, **version_base)
 
     if pre_num_fields > 0:
         if not to_dictconfig:
@@ -79,7 +81,7 @@ def test_launch_to_dictconfig(cfg, to_dictconfig):
             assert len(dataclasses.fields(cfg)) > 0
     else:
         # run again with no error
-        launch(cfg, task_function=task_fn, to_dictconfig=to_dictconfig)
+        launch(cfg, task_function=task_fn, to_dictconfig=to_dictconfig, **version_base)
 
 
 @pytest.mark.usefixtures("cleandir")
@@ -87,10 +89,7 @@ def test_launch_to_dictconfig(cfg, to_dictconfig):
     "overrides", [None, [], ["hydra.run.dir=test_hydra_overrided"]]
 )
 @pytest.mark.parametrize("with_log_configuration", [False, True])
-def test_launch_job(
-    overrides,
-    with_log_configuration,
-):
+def test_launch_job(overrides, with_log_configuration, version_base):
     cfg = dict(a=1, b=1)
     override_exists = overrides and len(overrides) > 1
 
@@ -99,6 +98,7 @@ def test_launch_job(
         task_function=instantiate,
         overrides=overrides,
         with_log_configuration=with_log_configuration,
+        **version_base,
     )
     assert job.return_value == {"a": 1, "b": 1}
 
@@ -113,9 +113,7 @@ def test_launch_job(
 @pytest.mark.parametrize("multirun_overrides", [None, ["a=1,2"]])
 @pytest.mark.parametrize("with_log_configuration", [False, True])
 def test_launch_multirun(
-    overrides,
-    multirun_overrides,
-    with_log_configuration,
+    overrides, multirun_overrides, with_log_configuration, version_base
 ):
     cfg = dict(a=1, b=1)
     override_exists = overrides and len(overrides) > 1
@@ -134,6 +132,7 @@ def test_launch_multirun(
         overrides=_overrides,
         with_log_configuration=with_log_configuration,
         multirun=True,
+        **version_base,
     )
     assert isinstance(job, list) and len(job) == 1
     for i, j in enumerate(job[0]):
@@ -144,11 +143,11 @@ def test_launch_multirun(
 
 
 @pytest.mark.usefixtures("cleandir")
-def test_launch_with_multirun_overrides():
+def test_launch_with_multirun_overrides(version_base):
     cfg = builds(dict, a=1, b=1)
     multirun_overrides = ["hydra/sweeper=basic", "a=1,2"]
     with pytest.raises(ConfigCompositionException):
-        launch(cfg, instantiate, overrides=multirun_overrides)
+        launch(cfg, instantiate, overrides=multirun_overrides, **version_base)
 
 
 ###############################################
@@ -191,10 +190,37 @@ cs.store(group="hydra/sweeper", name="local_test", node=builds(LocalBasicSweeper
     "plugin",
     [["hydra/sweeper=basic"], ["hydra/sweeper=local_test"]],
 )
-def test_launch_with_multirun_plugin(plugin):
+def test_launch_with_multirun_plugin(plugin, version_base):
     cfg = builds(dict, a=1, b=1)
     multirun_overrides = plugin + ["a=1,2"]
-    job = launch(cfg, instantiate, overrides=multirun_overrides, multirun=True)
+    job = launch(
+        cfg, instantiate, overrides=multirun_overrides, multirun=True, **version_base
+    )
     assert isinstance(job, list) and len(job) == 1 and len(job[0]) == 2
     for i, j in enumerate(job[0]):
         assert j.return_value == {"a": i + 1, "b": 1}
+
+
+@pytest.mark.skipif(HYDRA_VERSION < (1, 2, 0), reason="version_base not supported")
+@pytest.mark.parametrize("version_base", ["1.1", "1.2", None])
+@pytest.mark.usefixtures("cleandir")
+def test_version_base(version_base: Optional[str]):
+    def task(cfg):
+        (Path().cwd() / "foo.txt").touch()
+
+    expected_dir = Path().cwd() if version_base != "1.1" else (Path().cwd() / "outputs")
+
+    glob_pattern = "foo.txt" if version_base != "1.1" else "./**/foo.txt"
+
+    assert len(list(expected_dir.glob(glob_pattern))) == 0
+
+    launch(make_config(), task, version_base=version_base)
+    assert len(list(expected_dir.glob(glob_pattern))) == 1, list(expected_dir.glob("*"))
+
+    # ensure the file isn't found in the opposite location
+    not_found = (
+        Path().cwd().glob("foo.txt")
+        if version_base == "1.1"
+        else (Path().cwd() / "outputs").glob("**/foo.txt")
+    )
+    assert len(list(not_found)) == 0
diff --git a/tests/test_launch/test_logging.py b/tests/test_launch/test_logging.py
index 24769865..fac68b2b 100644
--- a/tests/test_launch/test_logging.py
+++ b/tests/test_launch/test_logging.py
@@ -8,6 +8,7 @@ import pytest
 from hydra.core.utils import JobReturn
 
 from hydra_zen import builds, instantiate, launch
+from hydra_zen._compatibility import HYDRA_VERSION
 
 log = logging.getLogger(__name__)
 
@@ -18,12 +19,22 @@ def task(cfg):
 
 
 @pytest.mark.usefixtures("cleandir")
-def test_consecutive_logs():
+def test_consecutive_logs(version_base):
+    overrides = ["hydra.run.dir=test"]
+    if HYDRA_VERSION >= (1, 2, 0):
+        overrides.append("hydra.job.chdir=True")
+
     job1 = launch(
-        builds(dict, message="1"), task_function=task, overrides=["hydra.run.dir=test"]
+        builds(dict, message="1"),
+        task_function=task,
+        overrides=overrides,
+        **version_base,
     )
     job2 = launch(
-        builds(dict, message="2"), task_function=task, overrides=["hydra.run.dir=test"]
+        builds(dict, message="2"),
+        task_function=task,
+        overrides=overrides,
+        **version_base,
     )
 
     assert isinstance(job1, JobReturn) and job1.working_dir is not None
@@ -38,12 +49,20 @@ def test_consecutive_logs():
 
 
 @pytest.mark.usefixtures("cleandir")
-def test_seperate_logs():
+def test_seperate_logs(version_base):
+    extra_overrides = ["hydra.job.chdir=True"] if HYDRA_VERSION >= (1, 2, 0) else []
+
     job1 = launch(
-        builds(dict, message="1"), task_function=task, overrides=["hydra.run.dir=test1"]
+        builds(dict, message="1"),
+        task_function=task,
+        overrides=["hydra.run.dir=test1"] + extra_overrides,
+        **version_base,
     )
     job2 = launch(
-        builds(dict, message="2"), task_function=task, overrides=["hydra.run.dir=test2"]
+        builds(dict, message="2"),
+        task_function=task,
+        overrides=["hydra.run.dir=test2"] + extra_overrides,
+        **version_base,
     )
 
     assert isinstance(job1, JobReturn) and job1.working_dir is not None
diff --git a/tests/test_launch/test_validation.py b/tests/test_launch/test_validation.py
index 6c96fdde..7a939198 100644
--- a/tests/test_launch/test_validation.py
+++ b/tests/test_launch/test_validation.py
@@ -24,11 +24,13 @@ from hydra_zen._launch import _store_config
     ],
 )
 @pytest.mark.parametrize("hydra_overrides", [[], ["hydra.run.dir=test"]])
-def test_launch_with_hydra_in_config(overrides, hydra_overrides, expected):
+def test_launch_with_hydra_in_config(
+    overrides, hydra_overrides, expected, version_base: dict
+):
     # validate hydra_launch executes properly if config contains
     # hydra configuration object
     cn = _store_config(builds(dict, a=1, b=1))
-    with initialize(config_path=None):
+    with initialize(config_path=None, **version_base):
         task_cfg = compose(
             config_name=cn, overrides=hydra_overrides, return_hydra_config=True
         )
@@ -40,8 +42,15 @@ def test_launch_with_hydra_in_config(overrides, hydra_overrides, expected):
     # Provide user override
     task_cfg.b = 10
 
+    if version_base:
+        overrides.append("hydra.job.chdir=True")
     # override works and user value is set
-    job = launch(task_cfg, task_function=instantiate, overrides=overrides)
+    job = launch(
+        task_cfg,
+        task_function=instantiate,
+        overrides=overrides,
+        **version_base,
+    )
     assert job.return_value == expected
     assert job.working_dir == "tested"
 
@@ -62,14 +71,19 @@ def test_launch_with_hydra_in_config(overrides, hydra_overrides, expected):
 )
 @pytest.mark.parametrize("hydra_overrides", [[], ["hydra.sweep.dir=test"]])
 def test_launch_with_multirun_with_hydra_in_config(
-    overrides, hydra_overrides, expected
+    overrides,
+    hydra_overrides,
+    expected,
+    version_base: dict,
 ):
     # validate hydra_launch executes properly if config contains
     # hydra configuration object
     cn = _store_config(builds(dict, a=1, b=1))
-    with initialize(config_path=None):
+    with initialize(config_path=None, **version_base):
         task_cfg = compose(
-            config_name=cn, overrides=hydra_overrides, return_hydra_config=True
+            config_name=cn,
+            overrides=hydra_overrides,
+            return_hydra_config=True,
         )
 
     assert "hydra" in task_cfg
@@ -79,9 +93,16 @@ def test_launch_with_multirun_with_hydra_in_config(
     # Provide user override
     task_cfg.b = 10
 
+    if version_base:
+        overrides.append("hydra.job.chdir=True")
+
     # override works and user value is set
     job = launch(
-        task_cfg, task_function=instantiate, overrides=overrides, multirun=True
+        task_cfg,
+        task_function=instantiate,
+        overrides=overrides,
+        multirun=True,
+        **version_base,
     )
     for e, j, k in zip(expected, job[0], range(len(expected))):
         assert j.return_value == e
@@ -89,16 +110,21 @@ def test_launch_with_multirun_with_hydra_in_config(
 
 
 @pytest.mark.usefixtures("cleandir")
-def test_launch_with_multirun_in_overrides():
+def test_launch_with_multirun_in_overrides(version_base):
     # hydra config is not in task_cfg but multirun overrides must be in multirun_overrides
     task_cfg = builds(dict, a=1)
 
     with pytest.raises(ConfigCompositionException):
-        launch(task_cfg, task_function=lambda _: print("hello"), overrides=["a=1,2"])
+        launch(
+            task_cfg,
+            task_function=lambda _: print("hello"),
+            overrides=["a=1,2"],
+            **version_base,
+        )
 
 
 @pytest.mark.usefixtures("cleandir")
-def test_launch_with_multirun_with_default_values():
+def test_launch_with_multirun_with_default_values(version_base):
     # Define configs in the configstore
     b2_cfg = builds(dict, b=2)
     b4_cfg = builds(dict, b=4)
@@ -114,6 +140,7 @@ def test_launch_with_multirun_with_default_values():
         task_function=lambda _: print("hello"),
         overrides=["+a=b2,b4"],
         multirun=True,
+        **version_base,
     )
 
     # need the + sign
@@ -123,6 +150,7 @@ def test_launch_with_multirun_with_default_values():
             task_function=lambda _: print("hello"),
             overrides=["a=b2,b4"],
             multirun=True,
+            **version_base,
         )
 
     # a default is set for "a"
@@ -133,4 +161,5 @@ def test_launch_with_multirun_with_default_values():
             task_function=lambda _: print("hello"),
             overrides=["a=b2,b4"],
             multirun=True,
+            **version_base,
         )