exp: add rename command (#9812)

* Add renaming for finished experiments

* Update arg parser

* Add rename by rev

* Add rename tests

* Change remove to rename; Refactor arguments error

* Remove --rev; Use resolve_name; Add test

* Update testing; Fix python3.10 rename failure

* Update rename test

* Update tests and error handling

* exp rename: Add `--force`.

Fail if experiment new_name exists but allow to overwrite it.

---------

Co-authored-by: daavoo <[email protected]>
This commit is contained in:
BradyJ27
2023-09-18 17:01:32 +00:00
committed by GitHub
co-authored by daavoo
parent 784a5b5199
commit 51dc78a135
7 changed files with 203 additions and 2 deletions
+2
View File
@@ -12,6 +12,7 @@ from dvc.commands.experiments import (
push,
queue_worker,
remove,
rename,
run,
save,
show,
@@ -28,6 +29,7 @@ SUB_COMMANDS = [
push,
queue_worker,
remove,
rename,
run,
save,
show,
+68
View File
@@ -0,0 +1,68 @@
import argparse
import logging
from dvc.cli.command import CmdBase
from dvc.cli.utils import append_doc_link
from dvc.exceptions import InvalidArgumentError
from dvc.ui import ui
logger = logging.getLogger(__name__)
class CmdExperimentsRename(CmdBase):
def run(self):
from dvc.utils import humanize
if not (self.args.experiment and self.args.name):
raise InvalidArgumentError(
"An experiment to rename and a new experiment name are required."
)
renamed = self.repo.experiments.rename(
exp_name=self.args.experiment,
new_name=self.args.name,
git_remote=self.args.git_remote,
force=self.args.force,
)
if renamed:
ui.write(f"Renamed experiments: {humanize.join(map(repr, renamed))}")
else:
ui.write("No experiments to rename.")
return 0
def add_parser(experiments_subparsers, parent_parser):
EXPERIMENTS_RENAME_HELP = "Rename experiments."
experiments_rename_parser = experiments_subparsers.add_parser(
"rename",
parents=[parent_parser],
description=append_doc_link(EXPERIMENTS_RENAME_HELP, "exp/rename"),
help=EXPERIMENTS_RENAME_HELP,
formatter_class=argparse.RawDescriptionHelpFormatter,
)
rename_group = experiments_rename_parser.add_mutually_exclusive_group()
rename_group.add_argument(
"-g",
"--git-remote",
metavar="<git_remote>",
help="Name or URL of the Git remote to rename the experiment from",
)
experiments_rename_parser.add_argument(
"experiment",
help="Experiment to rename.",
nargs="?",
metavar="<experiment>",
)
experiments_rename_parser.add_argument(
"name",
help="Name of new experiment.",
metavar="<name>",
)
experiments_rename_parser.add_argument(
"-f",
"--force",
action="store_true",
default=False,
help="Replace experiment if it already exists.",
)
experiments_rename_parser.set_defaults(func=CmdExperimentsRename)
+5
View File
@@ -395,6 +395,11 @@ class Experiments:
return remove(self.repo, *args, **kwargs)
def rename(self, *args, **kwargs):
from dvc.repo.experiments.rename import rename
return rename(self.repo, *args, **kwargs)
def clean(self, *args, **kwargs):
from dvc.repo.experiments.clean import clean
-2
View File
@@ -24,8 +24,6 @@ class ExperimentExistsError(DvcException):
"Experiment conflicts with existing experiment "
f"'{name}'. To overwrite the existing experiment run:\n\n"
f"\tdvc exp {command} -f ...\n\n"
f"To {command} this experiment with a different name run:\n\n"
f"\tdvc exp {command} -n <new_name> ...\n"
)
super().__init__(msg)
self.name = name
+60
View File
@@ -0,0 +1,60 @@
import logging
from typing import TYPE_CHECKING, Dict, List, Optional, Union
from dvc.repo.experiments.exceptions import (
ExperimentExistsError,
UnresolvedExpNamesError,
)
from dvc.repo.experiments.utils import resolve_name
from dvc.scm import Git
from .refs import ExpRefInfo
if TYPE_CHECKING:
from dvc.repo import Repo
logger = logging.getLogger(__name__)
def rename(
repo: "Repo",
new_name: str,
exp_name: Union[str, None] = None,
git_remote: Optional[str] = None,
force: bool = False,
) -> Union[List[str], None]:
renamed: List[str] = []
remained: List[str] = []
assert isinstance(repo.scm, Git)
if exp_name == new_name:
return None
if exp_name:
results: Dict[str, Union[ExpRefInfo, None]] = resolve_name(
scm=repo.scm, exp_names=exp_name, git_remote=git_remote
)
for name, result in results.items():
if result is None:
remained.append(name)
continue
new_ref = ExpRefInfo(baseline_sha=result.baseline_sha, name=new_name)
if repo.scm.get_ref(str(new_ref)) and not force:
raise ExperimentExistsError(new_name)
_rename_exp(scm=repo.scm, ref_info=result, new_name=new_name)
renamed.append(name)
if remained:
raise UnresolvedExpNamesError(remained, git_remote=git_remote)
return renamed
def _rename_exp(scm: "Git", ref_info: "ExpRefInfo", new_name: str):
rev = scm.get_ref(str(ref_info))
scm.remove_ref(str(ref_info))
ref_info.name = new_name
scm.set_ref(str(ref_info), rev)
return new_name
+33
View File
@@ -0,0 +1,33 @@
import pytest
from dvc.repo.experiments.exceptions import (
ExperimentExistsError,
UnresolvedExpNamesError,
)
from dvc.repo.experiments.utils import exp_refs_by_names
def test_rename_experiment_by_name(scm, dvc, exp_stage):
dvc.experiments.run(exp_stage.addressing, name="test-name", params=["foo=1"])
old_ref = exp_refs_by_names(scm, {"test-name"})
dvc.experiments.rename("new-name", "test-name")
new_ref = exp_refs_by_names(scm, {"new-name"})
assert scm.get_ref(str(old_ref["test-name"][0])) is None
assert scm.get_ref(str(new_ref["new-name"][0])) is not None
with pytest.raises(UnresolvedExpNamesError):
dvc.experiments.rename("new-name", "foo")
def test_same_name(dvc, exp_stage):
dvc.experiments.run(exp_stage.addressing, name="same-name", params=["foo=1"])
assert dvc.experiments.rename("same-name", "same-name") is None
def test_existing_name(dvc, exp_stage):
dvc.experiments.run(exp_stage.addressing, name="first-name", params=["foo=1"])
dvc.experiments.run(exp_stage.addressing, name="second-name", params=["foo=2"])
with pytest.raises(ExperimentExistsError):
dvc.experiments.rename("second-name", "first-name")
dvc.experiments.rename("second-name", "first-name", force=True)
+35
View File
@@ -9,6 +9,7 @@ from dvc.commands.experiments.ls import CmdExperimentsList
from dvc.commands.experiments.pull import CmdExperimentsPull
from dvc.commands.experiments.push import CmdExperimentsPush
from dvc.commands.experiments.remove import CmdExperimentsRemove
from dvc.commands.experiments.rename import CmdExperimentsRename
from dvc.commands.experiments.run import CmdExperimentsRun
from dvc.commands.experiments.save import CmdExperimentsSave
from dvc.commands.experiments.show import CmdExperimentsShow
@@ -418,6 +419,40 @@ def test_experiments_remove_invalid(dvc, scm, mocker, capsys, caplog):
)
def test_experiments_rename_flag(dvc, scm, mocker, capsys, caplog):
cli_args = parse_args(
[
"experiments",
"rename",
"--git-remote",
"myremote",
"exp-123",
"exp-234",
]
)
assert cli_args.func == CmdExperimentsRename
cmd = cli_args.func(cli_args)
m = mocker.patch("dvc.repo.experiments.rename.rename", return_value={})
assert cmd.run() == 0
m.assert_called_once_with(
cmd.repo,
exp_name="exp-123",
new_name="exp-234",
git_remote="myremote",
force=False,
)
def test_experiments_rename_invalid(dvc, scm, mocker, capsys, caplog):
cmd = CmdExperimentsRename(parse_args(["exp", "rename", "exp-1"]))
with pytest.raises(InvalidArgumentError) as excinfo:
cmd.run()
assert (
str(excinfo.value)
== "An experiment to rename and a new experiment name are required."
)
def test_experiments_save(dvc, scm, mocker):
cli_args = parse_args(["exp", "save", "--name", "exp-name", "--force"])
assert cli_args.func == CmdExperimentsSave