diff --git a/dvc/commands/experiments/__init__.py b/dvc/commands/experiments/__init__.py index 3d4b86875..8a966894f 100644 --- a/dvc/commands/experiments/__init__.py +++ b/dvc/commands/experiments/__init__.py @@ -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, diff --git a/dvc/commands/experiments/rename.py b/dvc/commands/experiments/rename.py new file mode 100644 index 000000000..4fd18e902 --- /dev/null +++ b/dvc/commands/experiments/rename.py @@ -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="", + 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="", + ) + experiments_rename_parser.add_argument( + "name", + help="Name of new experiment.", + metavar="", + ) + 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) diff --git a/dvc/repo/experiments/__init__.py b/dvc/repo/experiments/__init__.py index e351c15c3..b5e9d22d7 100644 --- a/dvc/repo/experiments/__init__.py +++ b/dvc/repo/experiments/__init__.py @@ -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 diff --git a/dvc/repo/experiments/exceptions.py b/dvc/repo/experiments/exceptions.py index 4436048be..eac56d55f 100644 --- a/dvc/repo/experiments/exceptions.py +++ b/dvc/repo/experiments/exceptions.py @@ -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 ...\n" ) super().__init__(msg) self.name = name diff --git a/dvc/repo/experiments/rename.py b/dvc/repo/experiments/rename.py new file mode 100644 index 000000000..fc0044d57 --- /dev/null +++ b/dvc/repo/experiments/rename.py @@ -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 diff --git a/tests/func/experiments/test_rename.py b/tests/func/experiments/test_rename.py new file mode 100644 index 000000000..3f484997a --- /dev/null +++ b/tests/func/experiments/test_rename.py @@ -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) diff --git a/tests/unit/command/test_experiments.py b/tests/unit/command/test_experiments.py index 582b9b45e..b18d9e973 100644 --- a/tests/unit/command/test_experiments.py +++ b/tests/unit/command/test_experiments.py @@ -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