Repository: iterative/dvc

Issue:
exp init: when to fail for non-existent params
From https://github.com/iterative/dvc/pull/6750#issuecomment-937219108:

> Can we validate that dependencies exist at the time of entry during interactive mode? It would save a lot of trouble with having to redo the whole stage after it fails at the end.

`exp init` fails if `params.yaml` does not exist since the params are parsed to generate the stage. Related: #6605.

In interactive mode, it would be nice to fail as soon as possible (right after path to params is provided).

For other dependencies, I wonder if `dvc exp init` should also validate that the paths exist. This could help guide users. It could be annoying if users want to do `dvc exp init` before creating projects files, but this seems unlikely to me outside of toy scenarios.


Discussion:
Should it fail, at all?

Maybe I'm a little out of context for intended `dvc exp init` usage but, would it be an unusual workflow in a fresh project to first use `dvc exp init` to bootstrap dvc stuff and later create related files (params, data deps)?
For params, we don't have a choice but to fail unless we implement #6605, because we need to specify all the individual parameter names in `dvc.yaml` when creating the stage. You can't really bootstrap the parameters at the start of the project right now like that since DVC needs to know each parameter name.

Without other changes in functionality, we can either fail only for a missing parameters file, or fail for any missing dependency. At the very least, in interactive mode, we shouldn't prompt them several more times and then fail because the parameters file is missing. For the primary use case of making it easy for new users, it might be useful to guide them towards first creating the necessary dependencies and not ending up with a broken stage.
It makes sense to fail right away for the params as we need them to exist for us to create a stage. I am not so sure about other dependencies, we tried to decouple the runtime behaviour with the metadata tracking behaviour on `stage add`.

I'll add a check for the params.



Resolving patch:
diff --git a/dvc/repo/experiments/init.py b/dvc/repo/experiments/init.py
--- a/dvc/repo/experiments/init.py
+++ b/dvc/repo/experiments/init.py
@@ -1,9 +1,18 @@
 import logging
 import os
 from contextlib import contextmanager
-from typing import TYPE_CHECKING, Dict, Iterable, cast
+from typing import (
+    TYPE_CHECKING,
+    Any,
+    Callable,
+    Dict,
+    Iterable,
+    List,
+    Optional,
+    cast,
+)
 
-from funcy import compact, lremove, post_processing
+from funcy import compact, lremove
 from rich.prompt import Confirm, Prompt
 from rich.rule import Rule
 from rich.syntax import Syntax
@@ -33,6 +42,10 @@
 }
 
 
+class RetryPrompt(Exception):
+    """Used for signalling whether prompts should be retried again."""
+
+
 class RichInputMixin:
     """Prevents exc message from printing in the same line on Ctrl + D/C."""
 
@@ -107,19 +120,35 @@ def make_prompt(self, default):
         return prompt
 
 
-@post_processing(dict)
-def _prompts(keys: Iterable[str], defaults: Dict[str, OptStr]):
-    for key in keys:
-        if key == "cmd":
-            prompt_cls = RequiredPrompt
-        else:
-            prompt_cls = SkippablePrompt
-        kwargs = {"default": defaults[key]} if key in defaults else {}
-        prompt = PROMPTS[key]
+def _prompt(
+    key: str,
+    validator: Optional[Callable[[str, Any], None]] = None,
+    default: OptStr = None,
+) -> str:
+    prompt_cls = RequiredPrompt if key == "cmd" else SkippablePrompt
+    kwargs = {"default": default} if default is not None else {}
+    while True:
         value = prompt_cls.ask(  # type: ignore[call-overload]
-            prompt, console=ui.error_console, **kwargs
+            PROMPTS[key], console=ui.error_console, **kwargs
         )
-        yield key, value
+        if validator:
+            try:
+                validator(key, value)
+            except RetryPrompt as exc:
+                ui.error_write(f"[red]{exc}[/]", styled=True)
+                continue
+        return value
+
+
+def _prompts(
+    keys: Iterable[str],
+    defaults: Dict[str, str],
+    validator: Callable[[str, Any], None] = None,
+) -> Dict[str, str]:
+    return {
+        key: _prompt(key, default=defaults.get(key), validator=validator)
+        for key in keys
+    }
 
 
 @contextmanager
@@ -143,6 +172,7 @@ def init_interactive(
     name: str,
     defaults: Dict[str, str],
     provided: Dict[str, str],
+    validator: Callable[[str, Any], None] = None,
     show_tree: bool = False,
     live: bool = False,
 ) -> Dict[str, str]:
@@ -190,8 +220,8 @@ def init_interactive(
         ui.error_write(tree, styled=True)
 
     ui.error_write()
-    ret.update(_prompts(primary, defaults))
-    ret.update(_prompts(secondary, defaults))
+    ret.update(_prompts(primary, defaults, validator=validator))
+    ret.update(_prompts(secondary, defaults, validator=validator))
     return compact(ret)
 
 
@@ -207,6 +237,13 @@ def _check_stage_exists(
         )
 
 
+def loadd_params(path: str) -> Dict[str, List[str]]:
+    from dvc.utils.serialize import LOADERS
+
+    _, ext = os.path.splitext(path)
+    return {path: list(LOADERS[ext](path))}
+
+
 def init(
     repo: "Repo",
     name: str = None,
@@ -227,9 +264,35 @@ def init(
     overrides = overrides or {}
 
     with_live = type == "live"
+
+    def validate_prompts_input(key: str, value: Any) -> None:
+        if value is None:
+            return
+
+        if key == "params":
+            assert isinstance(value, str)
+            try:
+                loadd_params(value)
+            except (FileNotFoundError, IsADirectoryError) as exc:
+                reason = "does not exist"
+                if isinstance(exc, IsADirectoryError):
+                    reason = "is a directory"
+                raise RetryPrompt(
+                    f"'{value}' {reason}. "
+                    "Please retry with an existing parameters file."
+                )
+        elif key in ("code", "data"):
+            if not os.path.exists(value):
+                ui.error_write(
+                    f"[yellow]'{value}' does not exist in the workspace. "
+                    '"exp run" may fail.[/]',
+                    styled=True,
+                )
+
     if interactive:
         defaults = init_interactive(
             name,
+            validator=validate_prompts_input,
             defaults=defaults,
             live=with_live,
             provided=overrides,
@@ -251,11 +314,7 @@ def init(
     params_kv = []
     params = context.get("params")
     if params:
-        from dvc.utils.serialize import LOADERS
-
-        assert isinstance(params, str)
-        _, ext = os.path.splitext(params)
-        params_kv = [{params: list(LOADERS[ext](params))}]
+        params_kv.append(loadd_params(params))
 
     checkpoint_out = bool(context.get("live"))
     models = context.get("models")


Test patch: