exp: Drop init. (#9269)

Drop `exp init`.

Closes #9267
This commit is contained in:
David de la Iglesia Castro
2023-05-19 14:39:16 +05:45
committed by GitHub
parent 3959c51373
commit cdba65f27e
7 changed files with 1 additions and 1531 deletions
-2
View File
@@ -8,7 +8,6 @@ from dvc.commands.experiments import (
diff,
exec_run,
gc,
init,
ls,
pull,
push,
@@ -26,7 +25,6 @@ SUB_COMMANDS = [
diff,
exec_run,
gc,
init,
ls,
pull,
push,
-219
View File
@@ -1,219 +0,0 @@
import argparse
import logging
from typing import TYPE_CHECKING, List
from funcy import compact
from dvc.cli.command import CmdBase
from dvc.cli.utils import append_doc_link
from dvc.exceptions import InvalidArgumentError
from dvc.ui import ui
if TYPE_CHECKING:
from dvc.dependency import Dependency
from dvc.stage import PipelineStage
logger = logging.getLogger(__name__)
class CmdExperimentsInit(CmdBase):
DEFAULT_NAME = "train"
CODE = "src"
DATA = "data"
MODELS = "models"
DEFAULT_METRICS = "metrics.json"
DEFAULT_PARAMS = "params.yaml"
PLOTS = "plots"
DEFAULTS = {
"code": CODE,
"data": DATA,
"models": MODELS,
"metrics": DEFAULT_METRICS,
"params": DEFAULT_PARAMS,
"plots": PLOTS,
}
def run(self):
from dvc.commands.stage import parse_cmd
cmd = parse_cmd(self.args.command)
if not self.args.interactive and not cmd:
raise InvalidArgumentError("command is not specified")
from dvc.repo.experiments.init import init
defaults = {}
if not self.args.explicit:
config = self.repo.config["exp"]
defaults.update({**self.DEFAULTS, **config})
cli_args = compact(
{
"cmd": cmd,
"code": self.args.code,
"data": self.args.data,
"models": self.args.models,
"metrics": self.args.metrics,
"params": self.args.params,
"plots": self.args.plots,
"live": self.args.live,
}
)
initialized_stage, initialized_deps, initialized_out_dirs = init(
self.repo,
name=self.args.name,
type=self.args.type,
defaults=defaults,
overrides=cli_args,
interactive=self.args.interactive,
force=self.args.force,
)
self._post_init_display(
initialized_stage, initialized_deps, initialized_out_dirs
)
if self.args.run:
self.repo.experiments.run(targets=[initialized_stage.addressing])
return 0
def _post_init_display(
self,
stage: "PipelineStage",
new_deps: List["Dependency"],
new_out_dirs: List[str],
) -> None:
from dvc.utils import humanize
path_fmt = "[green]{}[/green]".format
if new_deps:
deps_paths = humanize.join(map(path_fmt, new_deps))
ui.write(f"Creating dependencies: {deps_paths}", styled=True)
if new_out_dirs:
out_dirs_paths = humanize.join(map(path_fmt, new_out_dirs))
ui.write(f"Creating output directories: {out_dirs_paths}", styled=True)
ui.write(
f"Creating [b]{self.args.name}[/b] stage in [green]dvc.yaml[/]",
styled=True,
)
if stage.outs or not self.args.run:
# separate the above status-like messages with help/tips section
ui.write(styled=True)
if stage.outs:
outs_paths = humanize.join(map(path_fmt, stage.outs))
tips = f"Ensure your experiment command creates {outs_paths}."
ui.write(tips, styled=True)
if not self.args.run:
ui.write(
'You can now run your experiment using [b]"dvc exp run"[/].',
styled=True,
)
else:
# separate between `exp.run` output and `dvc exp init` output
ui.write(styled=True)
def add_parser(experiments_subparsers, parent_parser):
EXPERIMENTS_INIT_HELP = "Quickly setup any project to use experiments."
experiments_init_parser = experiments_subparsers.add_parser(
"init",
parents=[parent_parser],
description=append_doc_link(EXPERIMENTS_INIT_HELP, "exp/init"),
formatter_class=argparse.RawDescriptionHelpFormatter,
help=EXPERIMENTS_INIT_HELP,
)
experiments_init_parser.add_argument(
"command",
nargs=argparse.REMAINDER,
help="Command to execute.",
metavar="command",
)
experiments_init_parser.add_argument(
"--run",
action="store_true",
help="Run the experiment after initializing it",
)
experiments_init_parser.add_argument(
"--interactive",
"-i",
action="store_true",
help="Prompt for values that are not provided",
)
experiments_init_parser.add_argument(
"-f",
"--force",
action="store_true",
default=False,
help="Overwrite existing stage",
)
experiments_init_parser.add_argument(
"--explicit",
action="store_true",
default=False,
help="Only use the path values explicitly provided",
)
experiments_init_parser.add_argument(
"--name",
"-n",
help="Name of the stage to create (default: %(default)s)",
default=CmdExperimentsInit.DEFAULT_NAME,
)
experiments_init_parser.add_argument(
"--code",
help=(
"Path to the source file or directory "
"which your experiments depend"
f" (default: {CmdExperimentsInit.CODE})"
),
)
experiments_init_parser.add_argument(
"--data",
help=(
"Path to the data file or directory "
"which your experiments depend"
f" (default: {CmdExperimentsInit.DATA})"
),
)
experiments_init_parser.add_argument(
"--models",
help=(
"Path to the model file or directory for your experiments"
f" (default: {CmdExperimentsInit.MODELS})"
),
)
experiments_init_parser.add_argument(
"--params",
help=(
"Path to the parameters file for your experiments"
f" (default: {CmdExperimentsInit.DEFAULT_PARAMS})"
),
)
experiments_init_parser.add_argument(
"--metrics",
help=(
"Path to the metrics file for your experiments"
f" (default: {CmdExperimentsInit.DEFAULT_METRICS})"
),
)
experiments_init_parser.add_argument(
"--plots",
help=(
"Path to the plots file or directory for your experiments"
f" (default: {CmdExperimentsInit.PLOTS})"
),
)
experiments_init_parser.add_argument(
"--live",
help="Path to log dvclive outputs for your experiments",
)
experiments_init_parser.add_argument(
"--type",
choices=["default", "checkpoint"],
default="default",
help="Select type of stage to create (default: %(default)s)",
)
experiments_init_parser.set_defaults(func=CmdExperimentsInit)
-284
View File
@@ -1,284 +0,0 @@
import logging
import os
from contextlib import contextmanager
from functools import partial
from typing import (
TYPE_CHECKING,
Any,
Callable,
Dict,
Iterable,
List,
Optional,
TextIO,
Tuple,
Union,
)
from funcy import compact, lremove, lsplit
from dvc.exceptions import DvcException
from dvc.stage import PipelineStage
if TYPE_CHECKING:
from dvc.dependency import Dependency
from dvc.dvcfile import ProjectFile, SingleStageFile
from dvc.repo import Repo
from dvc.ui import ui
PROMPTS = {
"cmd": "[b]Command[/b] to execute",
"code": "Path to a [b]code[/b] file/directory",
"data": "Path to a [b]data[/b] file/directory",
"models": "Path to a [b]model[/b] file/directory",
"params": "Path to a [b]parameters[/b] file",
"metrics": "Path to a [b]metrics[/b] file",
"plots": "Path to a [b]plots[/b] file/directory",
}
def _prompts(
keys: Iterable[str],
defaults: Optional[Dict[str, str]] = None,
validator: Optional[Callable[[str, str], Union[str, Tuple[str, str]]]] = None,
allow_omission: bool = True,
stream: Optional[TextIO] = None,
) -> Dict[str, Optional[str]]:
from dvc.ui.prompt import Prompt
defaults = defaults or {}
return {
key: Prompt.prompt_(
PROMPTS[key],
console=ui.error_console,
default=defaults.get(key),
validator=partial(validator, key) if validator else None,
allow_omission=allow_omission,
stream=stream,
)
for key in keys
}
@contextmanager
def _disable_logging(highest_level=logging.CRITICAL):
previous_level = logging.root.manager.disable
logging.disable(highest_level)
try:
yield
finally:
logging.disable(previous_level)
def init_interactive(
defaults: Dict[str, str],
provided: Dict[str, str],
validator: Optional[Callable[[str, str], Union[str, Tuple[str, str]]]] = None,
stream: Optional[TextIO] = None,
) -> Dict[str, str]:
command_prompts = lremove(provided.keys(), ["cmd"])
dependencies_prompts = lremove(provided.keys(), ["code", "data", "params"])
output_keys = ["models"]
if "live" not in provided:
output_keys.extend(["metrics", "plots"])
outputs_prompts = lremove(provided.keys(), output_keys)
ret: Dict[str, str] = {}
if "cmd" in provided:
ret["cmd"] = provided["cmd"]
for heading, prompts, allow_omission in (
("", command_prompts, False),
("Enter experiment dependencies.", dependencies_prompts, True),
("Enter experiment outputs.", outputs_prompts, True),
):
if prompts and heading:
ui.error_write(heading, styled=True)
response = _prompts(
prompts,
defaults=defaults,
allow_omission=allow_omission,
validator=validator,
stream=stream,
)
ret.update(compact(response))
if prompts:
ui.error_write(styled=True)
return ret
def _check_stage_exists(
dvcfile: Union["ProjectFile", "SingleStageFile"],
name: str,
force: bool = False,
) -> None:
if not force and dvcfile.exists() and name in dvcfile.stages:
from dvc.stage.exceptions import DuplicateStageName
hint = "Use '--force' to overwrite."
raise DuplicateStageName(f"Stage '{name}' already exists in 'dvc.yaml'. {hint}")
def validate_prompts(repo: "Repo", key: str, value: str) -> Union[Any, Tuple[Any, str]]:
from dvc.ui.prompt import InvalidResponse
msg_format = "[yellow]'{0}' does not exist, the {1} will be created.[/]"
if key == "params":
from dvc.dependency.param import (
MissingParamsFile,
ParamsDependency,
ParamsIsADirectoryError,
)
assert isinstance(value, str)
try:
ParamsDependency(None, value, repo=repo).validate_filepath()
except MissingParamsFile:
return value, msg_format.format(value, "file")
except ParamsIsADirectoryError:
raise InvalidResponse( # noqa: B904
f"[prompt.invalid]'{value}' is a directory. "
"Please retry with an existing parameters file."
)
elif key in ("code", "data") and not os.path.exists(value):
typ = "file" if is_file(value) else "directory"
return value, msg_format.format(value, typ)
return value
def is_file(path: str) -> bool:
_, ext = os.path.splitext(path)
return bool(ext)
def init_deps(stage: PipelineStage) -> List["Dependency"]:
from funcy import rpartial
from dvc.dependency import ParamsDependency
from dvc.fs import localfs
new_deps = [dep for dep in stage.deps if not dep.exists]
params, deps = lsplit(rpartial(isinstance, ParamsDependency), new_deps)
# always create a file for params, detect file/folder based on extension
# for other dependencies
dirs = [dep.fs_path for dep in deps if not is_file(dep.fs_path)]
files = [dep.fs_path for dep in deps + params if is_file(dep.fs_path)]
for path in dirs:
localfs.makedirs(path)
for path in files:
localfs.makedirs(localfs.path.parent(path), exist_ok=True)
localfs.touch(path)
return new_deps
def init_out_dirs(stage: PipelineStage) -> List[str]:
from dvc.fs import localfs
new_dirs = []
# create dirs for outputs
for out in stage.outs:
path = out.def_path
if is_file(path):
path = localfs.path.parent(path)
if path and not localfs.exists(path):
localfs.makedirs(path)
new_dirs.append(path)
return new_dirs
def init(
repo: "Repo",
name: str = "train",
type: str = "default", # noqa: A002, pylint: disable=redefined-builtin
defaults: Optional[Dict[str, str]] = None,
overrides: Optional[Dict[str, str]] = None,
interactive: bool = False,
force: bool = False,
stream: Optional[TextIO] = None,
) -> Tuple[PipelineStage, List["Dependency"], List[str]]:
from dvc.dvcfile import PROJECT_FILE, load_file
dvcfile = load_file(repo, PROJECT_FILE)
_check_stage_exists(dvcfile, name, force=force)
defaults = defaults.copy() if defaults else {}
overrides = overrides.copy() if overrides else {}
if interactive:
defaults = init_interactive(
validator=partial(validate_prompts, repo),
defaults=defaults,
provided=overrides,
stream=stream,
)
elif "live" in overrides:
# suppress `metrics`/`plots` if live is selected.
defaults.pop("metrics", None)
defaults.pop("plots", None)
else:
defaults.pop("live", None) # suppress live otherwise
context: Dict[str, str] = {**defaults, **overrides}
assert "cmd" in context
params = context.get("params")
if params:
from dvc.dependency.param import (
MissingParamsFile,
ParamsDependency,
ParamsIsADirectoryError,
)
try:
ParamsDependency(None, params, repo=repo).validate_filepath()
except ParamsIsADirectoryError as exc:
raise DvcException(f"{exc}.") # noqa: B904 # swallow cause for display
except MissingParamsFile:
pass
if type == "checkpoint":
outs_key = "checkpoints"
metrics_key = "metrics_persist_no_cache"
plots_key = "plots_persist_no_cache"
else:
outs_key = "outs"
metrics_key = "metrics_no_cache"
plots_key = "plots_no_cache"
models = [context.get("models")]
metrics = [context.get("metrics")]
plots = [context.get("plots")]
if live_path := context.pop("live", None):
metrics.append(os.path.join(live_path, "metrics.json"))
plots.append(os.path.join(live_path, "plots"))
stage = repo.stage.create(
name=name,
cmd=context["cmd"],
deps=compact([context.get("code"), context.get("data")]),
params=[{params: None}] if params else None,
force=force,
**{
outs_key: compact(models),
metrics_key: compact(metrics),
plots_key: compact(plots),
},
)
assert isinstance(stage, PipelineStage)
with _disable_logging(), repo.scm_context(autostage=True, quiet=True):
stage.dump(update_lock=False)
initialized_out_dirs = init_out_dirs(stage)
stage.ignore_outs()
initialized_deps = init_deps(stage)
if params:
repo.scm_context.track_file(params)
return stage, initialized_deps, initialized_out_dirs
-177
View File
@@ -1,177 +0,0 @@
from typing import TYPE_CHECKING, Any, Callable, Optional, Tuple, Union
import rich.prompt
if TYPE_CHECKING:
from rich.console import Console as RichConsole
from . import RichText
TextType = Union["RichText", str]
class InvalidResponse(rich.prompt.InvalidResponse):
pass
class RichInputMixin:
"""Prevents exc message from printing in the same line on Ctrl + D/C."""
@classmethod
def get_input(cls, console: "RichConsole", *args, **kwargs) -> str:
try:
return super().get_input(console, *args, **kwargs) # type: ignore[misc]
except (KeyboardInterrupt, EOFError):
console.print()
raise
class Prompt(RichInputMixin, rich.prompt.Prompt):
"""Extended version supports custom validations and omission of values."""
omit_value: str = "n"
def __init__(
self,
*args: Any,
allow_omission: bool = False,
**kwargs: Any,
) -> None:
super().__init__(*args, **kwargs)
self.allow_omission: bool = allow_omission
@classmethod
def ask( # pylint: disable=arguments-differ
cls,
*args: Any,
allow_omission: bool = False,
validator: Optional[Callable[[str], Union[str, Tuple[str, str]]]] = None,
**kwargs: Any,
) -> str:
"""Extended to pass validator argument.
Args:
allow_omission: Allows omitting prompts
validator: Validates entered value through prompts
validator can raise an InvalidResponseError, after which
it'll reprompt user. It can also return:
- processed/modified value that will be return to the user.
- tuple of processed value and a message to show to the user.
Note that the validator is always required to return a value,
even if it does not process/modify it.
"""
default = kwargs.pop("default", None)
stream = kwargs.pop("stream", None)
return cls(*args, allow_omission=allow_omission, **kwargs)(
default=default if default is not None else ...,
stream=stream,
validator=validator,
)
@classmethod
def prompt_(
cls,
*args: Any,
allow_omission: bool = False,
validator: Optional[Callable[[str], Union[str, Tuple[str, str]]]] = None,
**kwargs: Any,
) -> Optional[str]:
"""Extends `.ask()` to allow skipping/returning None type."""
value = cls.ask(
*args, allow_omission=allow_omission, validator=validator, **kwargs
)
if allow_omission and value == cls.omit_value:
return None
return value
def __call__(
self,
*args: Any,
validator: Optional[Callable[[str], Union[str, Tuple[str, str]]]] = None,
**kwargs: Any,
) -> str:
"""Supports validating response and show warning message."""
# We cannot put this in `process_response` as it does not validate
# `default` values.
while True:
value = super().__call__(*args, **kwargs)
if validator is None or (self.allow_omission and value == self.omit_value):
return value
try:
validated = validator(value)
except InvalidResponse as exc:
self.on_validate_error(value, exc)
continue
else:
if isinstance(validated, tuple):
value, message = validated
else:
value, message = validated, ""
if message:
self.console.print(message)
return value
def process_response(self, value: str) -> str:
"""Disallow empty values."""
ret = super().process_response(value)
if not ret:
raise InvalidResponse(
"[prompt.invalid]Response required. Please try again."
)
return ret
def render_default(self, default):
from rich.text import Text
return Text(f"{default!s}", "green")
def make_prompt(self, default):
prompt = self.prompt.copy()
prompt.end = ""
parts = []
if (
default is not ...
and self.show_default
and isinstance(default, (str, self.response_type))
):
_default = self.render_default(default)
parts.append(_default)
if self.allow_omission:
from rich.text import Text
if parts:
parts.append(", ")
parts.append(Text(f"{self.omit_value} to omit", style="italic"))
if parts:
parts = [" [", *parts, "]"]
for part in parts:
prompt.append(part)
prompt.append(self.prompt_suffix)
return prompt
class Confirm(RichInputMixin, rich.prompt.Confirm):
def make_prompt(self, default):
prompt = self.prompt.copy()
prompt.end = ""
prompt.append(" [")
yes, no = self.choices
for idx, (val, choice) in enumerate(((True, yes), (False, no))):
if idx:
prompt.append("/")
if val == default:
prompt.append(choice.upper(), "green")
else:
prompt.append(choice)
prompt.append("]")
prompt.append(self.prompt_suffix)
return prompt
-500
View File
@@ -1,500 +0,0 @@
import io
import os
import pytest
from dvc.cli import main
from dvc.commands.experiments.init import CmdExperimentsInit
from dvc.repo.experiments.init import init
from dvc.stage.exceptions import DuplicateStageName
# the tests may hang on prompts on failure
pytestmark = pytest.mark.timeout(3, func_only=True)
@pytest.mark.timeout(5, func_only=True)
def test_init_simple(tmp_dir, scm, dvc, capsys):
tmp_dir.gen(
{
CmdExperimentsInit.CODE: {"copy.py": ""},
"data": "data",
"params.yaml": '{"foo": 1}',
"plots": {},
}
)
code_path = os.path.join(CmdExperimentsInit.CODE, "copy.py")
script = f"python {code_path}"
capsys.readouterr()
assert main(["exp", "init", script]) == 0
out, err = capsys.readouterr()
assert not err
assert (
"Creating train stage in dvc.yaml\n\n"
"Ensure your experiment command creates metrics.json, plots and models"
'.\nYou can now run your experiment using "dvc exp run".' in out
)
assert (tmp_dir / "dvc.yaml").parse() == {
"stages": {
"train": {
"cmd": script,
"deps": ["data", "src"],
"metrics": [{"metrics.json": {"cache": False}}],
"outs": ["models"],
"params": [{"params.yaml": None}],
"plots": [{"plots": {"cache": False}}],
}
}
}
assert (tmp_dir / "data").read_text() == "data"
assert (tmp_dir / "src").is_dir()
@pytest.mark.parametrize("interactive", [True, False])
def test_when_stage_already_exists_with_same_name(tmp_dir, dvc, interactive):
(tmp_dir / "dvc.yaml").dump({"stages": {"train": {"cmd": "test"}}})
with pytest.raises(DuplicateStageName) as exc:
init(
dvc,
interactive=interactive,
overrides={"cmd": "true"},
defaults=CmdExperimentsInit.DEFAULTS,
)
assert (
str(exc.value)
== "Stage 'train' already exists in 'dvc.yaml'. Use '--force' to overwrite."
)
def test_when_stage_force_if_already_exists(tmp_dir, dvc):
(tmp_dir / "params.yaml").dump({"foo": 1})
(tmp_dir / "dvc.yaml").dump({"stages": {"train": {"cmd": "test"}}})
init(
dvc,
force=True,
overrides={"cmd": "true"},
defaults=CmdExperimentsInit.DEFAULTS,
)
d = (tmp_dir / "dvc.yaml").parse()
assert d["stages"]["train"]["cmd"] == "true"
@pytest.mark.parametrize("interactive", [True, False])
def test_creates_params_file_by_default(tmp_dir, dvc, interactive):
init(
dvc,
interactive=interactive,
defaults=CmdExperimentsInit.DEFAULTS,
overrides={"cmd": "cmd"},
stream=io.StringIO(""),
)
assert (tmp_dir / "params.yaml").is_file()
assert (tmp_dir / "params.yaml").parse() == {}
def test_with_a_custom_name(tmp_dir, dvc):
init(dvc, name="custom", overrides={"cmd": "cmd"})
assert (tmp_dir / "dvc.yaml").parse() == {"stages": {"custom": {"cmd": "cmd"}}}
def test_init_with_no_defaults_non_interactive(tmp_dir, scm, dvc):
init(dvc, defaults={}, overrides={"cmd": "python script.py"})
assert (tmp_dir / "dvc.yaml").parse() == {
"stages": {"train": {"cmd": "python script.py"}}
}
scm._reset()
assert not (tmp_dir / "dvc.lock").exists()
assert scm.is_tracked("dvc.yaml")
@pytest.mark.parametrize(
"extra_overrides, inp",
[
({"cmd": "cmd"}, io.StringIO()),
({}, io.StringIO("cmd")),
],
)
def test_init_interactive_when_no_path_prompts_need_to_be_asked(
tmp_dir, dvc, extra_overrides, inp
):
"""When we pass everything that's required of, it should not prompt us."""
(tmp_dir / "params.yaml").dump({"foo": 1})
init(
dvc,
interactive=True,
defaults=CmdExperimentsInit.DEFAULTS,
overrides={**CmdExperimentsInit.DEFAULTS, **extra_overrides},
stream=inp,
)
assert (tmp_dir / "dvc.yaml").parse() == {
"stages": {
"train": {
"cmd": "cmd",
"deps": ["data", "src"],
"metrics": [
{"metrics.json": {"cache": False}},
],
"outs": ["models"],
"params": [{"params.yaml": None}],
"plots": [
{"plots": {"cache": False}},
],
}
}
}
assert (tmp_dir / "src").is_dir()
assert (tmp_dir / "data").is_dir()
def test_when_params_is_omitted_in_interactive_mode(tmp_dir, scm, dvc):
(tmp_dir / "params.yaml").dump({"foo": 1})
inp = io.StringIO("python script.py\nscript.py\ndata\nn")
init(dvc, interactive=True, stream=inp, defaults=CmdExperimentsInit.DEFAULTS)
assert (tmp_dir / "dvc.yaml").parse() == {
"stages": {
"train": {
"cmd": "python script.py",
"deps": ["data", "script.py"],
"metrics": [{"metrics.json": {"cache": False}}],
"outs": ["models"],
"plots": [{"plots": {"cache": False}}],
}
}
}
assert not (tmp_dir / "dvc.lock").exists()
assert not (tmp_dir / "script.py").read_text()
assert (tmp_dir / "data").is_dir()
scm._reset()
assert scm.is_tracked("dvc.yaml")
assert not scm.is_tracked("params.yaml")
assert scm.is_tracked(".gitignore")
assert scm.is_ignored("models")
def test_init_interactive_params_validation(tmp_dir, dvc, capsys):
tmp_dir.gen({"data": {"foo": "foo"}})
(tmp_dir / "params.yaml").dump({"foo": 1})
inp = io.StringIO("python script.py\nscript.py\ndata\ndata")
init(dvc, stream=inp, interactive=True, defaults=CmdExperimentsInit.DEFAULTS)
assert (tmp_dir / "dvc.yaml").parse() == {
"stages": {
"train": {
"cmd": "python script.py",
"deps": ["data", "script.py"],
"metrics": [{"metrics.json": {"cache": False}}],
"outs": ["models"],
"params": [{"params.yaml": None}],
"plots": [{"plots": {"cache": False}}],
}
}
}
assert not (tmp_dir / "script.py").read_text()
assert (tmp_dir / "data").is_dir()
out, err = capsys.readouterr()
assert not out
assert (
"Path to a parameters file [params.yaml, n to omit]: "
"'data' is a directory. "
"Please retry with an existing parameters file.\n"
"Path to a parameters file [params.yaml, n to omit]:" in err
)
def test_init_with_no_defaults_interactive(tmp_dir, dvc):
inp = io.StringIO("script.py\ndata\nn\nmodel\nmetric\nn\n")
init(
dvc,
defaults={},
overrides={"cmd": "python script.py"},
interactive=True,
stream=inp,
)
assert (tmp_dir / "dvc.yaml").parse() == {
"stages": {
"train": {
"cmd": "python script.py",
"deps": ["data", "script.py"],
"metrics": [{"metric": {"cache": False}}],
"outs": ["model"],
}
}
}
assert not (tmp_dir / "script.py").read_text()
assert (tmp_dir / "data").is_dir()
@pytest.mark.parametrize(
"interactive, overrides, inp",
[
(False, {"cmd": "python script.py", "code": "script.py"}, None),
(
True,
{},
io.StringIO(
"python script.py\n"
"script.py\n"
"data\n"
"params.yaml\n"
"models\n"
"metrics.json\n"
"plots\n"
"y"
),
),
],
ids=["non-interactive", "interactive"],
)
def test_init_default(tmp_dir, scm, dvc, interactive, overrides, inp, capsys):
(tmp_dir / "params.yaml").dump({"foo": {"bar": 1}})
init(
dvc,
interactive=interactive,
defaults=CmdExperimentsInit.DEFAULTS,
overrides=overrides,
stream=inp,
)
assert (tmp_dir / "dvc.yaml").parse() == {
"stages": {
"train": {
"cmd": "python script.py",
"deps": ["data", "script.py"],
"metrics": [{"metrics.json": {"cache": False}}],
"outs": ["models"],
"params": [{"params.yaml": None}],
"plots": [{"plots": {"cache": False}}],
}
}
}
assert not (tmp_dir / "dvc.lock").exists()
assert not (tmp_dir / "script.py").read_text()
assert (tmp_dir / "data").is_dir()
scm._reset()
assert scm.is_tracked("dvc.yaml")
assert scm.is_tracked("params.yaml")
assert scm.is_tracked(".gitignore")
assert scm.is_ignored("models")
out, err = capsys.readouterr()
assert not out
if interactive:
assert "'script.py' does not exist, the file will be created." in err
assert "'data' does not exist, the directory will be created." in err
@pytest.mark.timeout(5, func_only=True)
@pytest.mark.parametrize(
"interactive, overrides, inp",
[
(False, {"cmd": "python script.py", "code": "script.py"}, None),
(
True,
{},
io.StringIO("python script.py\nscript.py\ndata\nparams.yaml\nmodels\ny"),
),
(
True,
{"cmd": "python script.py"},
io.StringIO("script.py\ndata\nparams.yaml\nmodels\ny"),
),
(
True,
{"cmd": "python script.py", "models": "models"},
io.StringIO("script.py\ndata\nparams.yaml\ny"),
),
],
ids=[
"non-interactive",
"interactive",
"interactive-cmd-provided",
"interactive-cmd-models-provided",
],
)
def test_init_interactive_live(tmp_dir, scm, dvc, interactive, overrides, inp, capsys):
overrides["live"] = "dvclive"
(tmp_dir / "params.yaml").dump({"foo": {"bar": 1}})
init(
dvc,
interactive=interactive,
defaults=CmdExperimentsInit.DEFAULTS,
overrides=overrides,
stream=inp,
)
assert (tmp_dir / "dvc.yaml").parse() == {
"stages": {
"train": {
"cmd": "python script.py",
"deps": ["data", "script.py"],
"metrics": [
{os.path.join("dvclive", "metrics.json"): {"cache": False}}
],
"outs": ["models"],
"params": [{"params.yaml": None}],
"plots": [{os.path.join("dvclive", "plots"): {"cache": False}}],
}
}
}
assert not (tmp_dir / "dvc.lock").exists()
assert not (tmp_dir / "script.py").read_text()
assert (tmp_dir / "data").is_dir()
scm._reset()
assert scm.is_tracked("dvc.yaml")
assert scm.is_tracked("params.yaml")
assert scm.is_tracked(".gitignore")
assert scm.is_ignored("models")
out, err = capsys.readouterr()
assert not out
if interactive:
assert "'script.py' does not exist, the file will be created." in err
assert "'data' does not exist, the directory will be created." in err
@pytest.mark.parametrize(
"interactive, inp",
[
(False, None),
(True, io.StringIO()),
],
)
def test_init_with_type_checkpoint_and_models_plots_provided(
tmp_dir, dvc, interactive, inp
):
(tmp_dir / "params.yaml").dump({"foo": 1})
init(
dvc,
type="checkpoint",
interactive=interactive,
stream=inp,
defaults=CmdExperimentsInit.DEFAULTS,
overrides={"cmd": "cmd", "metrics": "m", "plots": "p"},
)
assert (tmp_dir / "dvc.yaml").parse() == {
"stages": {
"train": {
"cmd": "cmd",
"deps": ["data", "src"],
"metrics": [
{"m": {"cache": False, "persist": True}},
],
"outs": [{"models": {"checkpoint": True}}],
"params": [{"params.yaml": None}],
"plots": [
{"p": {"cache": False, "persist": True}},
],
}
}
}
assert (tmp_dir / "src").is_dir()
assert (tmp_dir / "data").is_dir()
@pytest.mark.parametrize(
"interactive, inp",
[
(False, None),
(True, io.StringIO()),
],
)
def test_init_with_type_default_and_live_provided(tmp_dir, dvc, interactive, inp):
(tmp_dir / "params.yaml").dump({"foo": 1})
init(
dvc,
interactive=interactive,
stream=inp,
defaults=CmdExperimentsInit.DEFAULTS,
overrides={"cmd": "cmd", "live": "live"},
)
assert (tmp_dir / "dvc.yaml").parse() == {
"stages": {
"train": {
"cmd": "cmd",
"deps": ["data", "src"],
"metrics": [
{os.path.join("live", "metrics.json"): {"cache": False}},
],
"outs": ["models"],
"params": [{"params.yaml": None}],
"plots": [
{os.path.join("live", "plots"): {"cache": False}},
],
}
}
}
assert (tmp_dir / "src").is_dir()
assert (tmp_dir / "data").is_dir()
@pytest.mark.parametrize(
"interactive, inp",
[
(False, None),
(True, io.StringIO()),
],
)
def test_init_with_live_and_metrics_plots_provided(tmp_dir, dvc, interactive, inp):
(tmp_dir / "params.yaml").dump({"foo": 1})
init(
dvc,
interactive=interactive,
stream=inp,
defaults=CmdExperimentsInit.DEFAULTS,
overrides={
"cmd": "cmd",
"live": "live",
"metrics": "metrics.json",
"plots": "plots",
},
)
assert (tmp_dir / "dvc.yaml").parse() == {
"stages": {
"train": {
"cmd": "cmd",
"deps": ["data", "src"],
"metrics": [
{os.path.join("live", "metrics.json"): {"cache": False}},
{"metrics.json": {"cache": False}},
],
"outs": ["models"],
"params": [{"params.yaml": None}],
"plots": [
{os.path.join("live", "plots"): {"cache": False}},
{"plots": {"cache": False}},
],
}
}
}
assert (tmp_dir / "src").is_dir()
assert (tmp_dir / "data").is_dir()
def test_gen_output_dirs(tmp_dir, dvc):
init(
dvc,
defaults=CmdExperimentsInit.DEFAULTS,
overrides={
"cmd": "cmd",
"models": "models/predict.h5",
"metrics": "eval/scores.json",
"plots": "eval/plots",
"live": "eval/live",
},
)
assert (tmp_dir / "models").is_dir()
assert (tmp_dir / "eval").is_dir()
assert (tmp_dir / "eval/plots").is_dir()
assert (tmp_dir / "eval/live").is_dir()
assert not (tmp_dir / "models/predict.h5").exists()
assert not (tmp_dir / "eval/scores.json").exists()
+1 -205
View File
@@ -1,14 +1,11 @@
import pathlib
import pytest
from dvc.cli import DvcParserError, parse_args
from dvc.cli import parse_args
from dvc.commands.experiments.apply import CmdExperimentsApply
from dvc.commands.experiments.branch import CmdExperimentsBranch
from dvc.commands.experiments.clean import CmdExperimentsClean
from dvc.commands.experiments.diff import CmdExperimentsDiff
from dvc.commands.experiments.gc import CmdExperimentsGC
from dvc.commands.experiments.init import CmdExperimentsInit
from dvc.commands.experiments.ls import CmdExperimentsList
from dvc.commands.experiments.pull import CmdExperimentsPull
from dvc.commands.experiments.push import CmdExperimentsPush
@@ -17,9 +14,6 @@ from dvc.commands.experiments.run import CmdExperimentsRun
from dvc.commands.experiments.save import CmdExperimentsSave
from dvc.commands.experiments.show import CmdExperimentsShow
from dvc.exceptions import InvalidArgumentError
from dvc.repo import Repo
from tests.utils import ANY
from tests.utils.asserts import called_once_with_subset
from .test_repro import common_arguments as repro_arguments
@@ -420,204 +414,6 @@ def test_experiments_remove_invalid(dvc, scm, mocker, capsys, caplog):
)
@pytest.mark.parametrize("extra_args", [(), ("--run",)])
def test_experiments_init(dvc, scm, mocker, capsys, extra_args):
stage = mocker.Mock(outs=[], addressing="train")
m = mocker.patch("dvc.repo.experiments.init.init", return_value=(stage, [], []))
runner = mocker.patch("dvc.repo.experiments.run.run", return_value=0)
cli_args = parse_args(["exp", "init", *extra_args, "cmd"])
cmd = cli_args.func(cli_args)
assert isinstance(cmd, CmdExperimentsInit)
assert cmd.run() == 0
m.assert_called_once_with(
ANY(Repo),
name="train",
type="default",
defaults={
"code": "src",
"models": "models",
"data": "data",
"metrics": "metrics.json",
"params": "params.yaml",
"plots": "plots",
},
overrides={"cmd": "cmd"},
interactive=False,
force=False,
)
if extra_args:
# `parse_args` creates a new `Repo` object
runner.assert_called_once_with(ANY(Repo), targets=["train"])
def test_experiments_init_config(dvc, scm, mocker):
with dvc.config.edit() as conf:
conf["exp"] = {"code": "new_src", "models": "new_models"}
stage = mocker.Mock(outs=[])
m = mocker.patch("dvc.repo.experiments.init.init", return_value=(stage, [], []))
cli_args = parse_args(["exp", "init", "cmd"])
cmd = cli_args.func(cli_args)
assert isinstance(cmd, CmdExperimentsInit)
assert cmd.run() == 0
m.assert_called_once_with(
ANY(Repo),
name="train",
type="default",
defaults={
"code": "new_src",
"models": "new_models",
"data": "data",
"metrics": "metrics.json",
"params": "params.yaml",
"plots": "plots",
},
overrides={"cmd": "cmd"},
interactive=False,
force=False,
)
def test_experiments_init_explicit(dvc, mocker):
stage = mocker.Mock(outs=[])
m = mocker.patch("dvc.repo.experiments.init.init", return_value=(stage, [], []))
cli_args = parse_args(["exp", "init", "--explicit", "cmd"])
cmd = cli_args.func(cli_args)
assert cmd.run() == 0
m.assert_called_once_with(
ANY(Repo),
name="train",
type="default",
defaults={},
overrides={"cmd": "cmd"},
interactive=False,
force=False,
)
def test_experiments_init_cmd_required_for_non_interactive_mode(dvc):
cli_args = parse_args(["exp", "init"])
cmd = cli_args.func(cli_args)
assert isinstance(cmd, CmdExperimentsInit)
with pytest.raises(InvalidArgumentError) as exc:
cmd.run()
assert str(exc.value) == "command is not specified"
def test_experiments_init_cmd_not_required_for_interactive_mode(dvc, mocker):
cli_args = parse_args(["exp", "init", "--interactive"])
cmd = cli_args.func(cli_args)
assert isinstance(cmd, CmdExperimentsInit)
stage = mocker.Mock(outs=[])
m = mocker.patch("dvc.repo.experiments.init.init", return_value=(stage, [], []))
assert cmd.run() == 0
assert called_once_with_subset(m, ANY(Repo), interactive=True)
@pytest.mark.parametrize(
"extra_args, expected_kw",
[
(["--type", "default"], {"type": "default", "name": "train"}),
(["--type", "checkpoint"], {"type": "checkpoint", "name": "train"}),
(["--force"], {"force": True, "name": "train"}),
(
["--name", "name", "--type", "checkpoint"],
{"name": "name", "type": "checkpoint"},
),
(
[
"--plots",
"p",
"--models",
"m",
"--code",
"c",
"--metrics",
"m.json",
"--params",
"p.yaml",
"--data",
"d",
"--live",
"live",
],
{
"name": "train",
"overrides": {
"plots": "p",
"models": "m",
"code": "c",
"metrics": "m.json",
"params": "p.yaml",
"data": "d",
"live": "live",
"cmd": "cmd",
},
},
),
],
)
def test_experiments_init_extra_args(extra_args, expected_kw, mocker):
cli_args = parse_args(["exp", "init", *extra_args, "cmd"])
cmd = cli_args.func(cli_args)
assert isinstance(cmd, CmdExperimentsInit)
stage = mocker.Mock(outs=[])
m = mocker.patch("dvc.repo.experiments.init.init", return_value=(stage, [], []))
assert cmd.run() == 0
assert called_once_with_subset(m, ANY(Repo), **expected_kw)
def test_experiments_init_type_invalid_choice():
with pytest.raises(DvcParserError):
parse_args(["exp", "init", "--type=invalid", "cmd"])
@pytest.mark.parametrize("args", [[], ["--run"]])
def test_experiments_init_displays_output_on_no_run(dvc, mocker, capsys, args):
model_dir = pathlib.Path("models")
model_path = str(model_dir / "predict.h5")
stage = dvc.stage.create(
name="train",
cmd=["cmd"],
deps=["code", "data"],
params=["params.yaml"],
outs=["metrics.json", "plots", model_path],
)
mocker.patch(
"dvc.repo.experiments.init.init",
return_value=(stage, stage.deps, [model_dir]),
)
mocker.patch("dvc.repo.experiments.run.run", return_value=0)
cli_args = parse_args(["exp", "init", "cmd", *args])
cmd = cli_args.func(cli_args)
assert cmd.run() == 0
expected_lines = [
"Creating dependencies: code, data and params.yaml",
"Creating output directories: models",
"Creating train stage in dvc.yaml",
"",
"Ensure your experiment command creates metrics.json, plots and ",
f"{model_path}.",
]
if not cli_args.run:
expected_lines += [
'You can now run your experiment using "dvc exp run".',
]
out, err = capsys.readouterr()
assert not err
assert out.splitlines() == expected_lines
def test_experiments_save(dvc, scm, mocker):
cli_args = parse_args(["exp", "save", "--name", "exp-name", "--force"])
assert cli_args.func == CmdExperimentsSave
-144
View File
@@ -1,144 +0,0 @@
import io
from unittest.mock import call
import pytest
from rich.console import Console as RichConsole
from dvc.ui.prompt import Confirm, InvalidResponse, Prompt
@pytest.mark.parametrize("side_effect", [EOFError, KeyboardInterrupt])
@pytest.mark.parametrize("prompt_cls", [Prompt, Confirm])
def test_prompt_interrupt_message_not_interleaved(prompt_cls, side_effect, mocker):
"""Test that the interrupt message is not interleaved with the prompt."""
prompt = "should print exception in the next line"
console = RichConsole(file=io.StringIO())
mocker.patch.object(console, "input", side_effect=side_effect)
with pytest.raises(side_effect):
prompt_cls.ask(prompt=prompt, console=console)
# as we are raising side_effect on the input, unfortunately we cannot
# assert for the prompt string, only the newline.
assert console.file.getvalue() == "\n"
def test_prompt_str():
console = RichConsole(file=io.StringIO())
assert (
Prompt.ask("What is your name?", console=console, stream=io.StringIO("foo"))
== "foo"
)
assert console.file.getvalue() == "What is your name?: "
def test_prompt_disallows_empty_response(mocker):
console = RichConsole(file=io.StringIO())
Prompt.prompt_(
"What is your name?",
console=console,
stream=io.StringIO("\nfoo"),
)
expected = (
"What is your name?: Response required. Please try again.\nWhat is your name?: "
)
assert console.file.getvalue() == expected
@pytest.mark.parametrize(
"default, brack",
[
("default", "[default, n to omit]"),
(None, "[n to omit]"),
],
)
def test_prompt_with_conditional_skip(mocker, default, brack):
console = RichConsole(file=io.StringIO())
mocked_validator = mocker.MagicMock(return_value="something")
assert (
Prompt.prompt_(
"What is your name?",
console=console,
allow_omission=True,
stream=io.StringIO("n\n"),
default=default,
validator=mocked_validator,
)
is None
)
assert console.file.getvalue() == f"What is your name? {brack}: "
mocked_validator.assert_not_called()
def test_prompt_retries_on_invalid_response_from_validator(mocker):
console = RichConsole(file=io.StringIO())
mocked_validator = mocker.MagicMock(
side_effect=[InvalidResponse("it is a number"), "foo"]
)
assert (
Prompt.ask(
"what is your name?",
console=console,
stream=io.StringIO("3\nfoo"),
validator=mocked_validator,
)
== "foo"
)
mocked_validator.assert_has_calls([call("3"), call("foo")])
expected = "what is your name?: it is a number\nwhat is your name?: "
assert console.file.getvalue() == expected
def test_prompt_shows_message_from_validator_response(mocker):
console = RichConsole(file=io.StringIO())
mocked_validator = mocker.MagicMock(
return_value=("foo@email", "failed to send a verification email")
)
assert (
Prompt.ask(
"what is your email?",
console=console,
stream=io.StringIO("foo@email"),
validator=mocked_validator,
)
== "foo@email"
)
mocked_validator.assert_has_calls([call("foo@email")])
expected = "what is your email?: failed to send a verification email\n"
assert console.file.getvalue() == expected
def test_prompt_default(mocker):
console = RichConsole(file=io.StringIO())
mocked_validator = mocker.MagicMock(return_value="default")
assert (
Prompt.ask(
"What is your name?",
console=console,
stream=io.StringIO(""),
default="default",
validator=mocked_validator,
)
== "default"
)
assert console.file.getvalue() == "What is your name? [default]: "
mocked_validator.assert_has_calls([call("default")])
@pytest.mark.parametrize(
"default, inner", [(True, "Y/n"), (False, "y/N"), (Ellipsis, "y/n")]
)
def test_prompt_confirm(default, inner):
console = RichConsole(file=io.StringIO())
assert (
Confirm.ask(
"exit?",
console=console,
stream=io.StringIO("y\n"),
default=default,
)
is True
)
output = console.file.getvalue()
assert output == f"exit? [{inner}]: "