From 51fa0f5c3896f2cb4cd2a00194be1530a3c5bece Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Peter=20Rowlands=20=28=EB=B3=80=EA=B8=B0=ED=98=B8=29?= Date: Tue, 8 Dec 2020 21:20:03 +0900 Subject: [PATCH] experiments: add `dvc exp push/pull/list` (#5042) * git: add support for on_diverged in push_refspec * experiments: update executor for push-refspec changes * experiments: implement push() * tests: add tests for experiments.push * git: add iter_remote_refs * roughly equivalent to `git ls-remote --refs` * experiments: add experiments.list for listing local and remote experiments * refactor git upstream/downstream in tests * experiments: add experiments.pull * experiments: add CLI commands for push/pull/list * exp list: drop short options * experiments: s/list/ls/ * dulwich: catch not repo errors when creating remote client * fix remote rev resolution for list * fix command test * add tests for push/pull with ambiguous experiment names --- dvc/command/experiments.py | 144 +++++++++++++ dvc/repo/experiments/__init__.py | 15 ++ dvc/repo/experiments/executor.py | 9 +- dvc/repo/experiments/ls.py | 50 +++++ dvc/repo/experiments/pull.py | 57 +++++ dvc/repo/experiments/push.py | 57 +++++ dvc/repo/experiments/utils.py | 41 ++++ dvc/scm/git/__init__.py | 1 + dvc/scm/git/backend/base.py | 30 ++- dvc/scm/git/backend/dulwich.py | 120 ++++++++--- dvc/scm/git/backend/gitpython.py | 14 +- tests/func/experiments/test_experiments.py | 35 ++- tests/func/experiments/test_remote.py | 240 +++++++++++++++++++++ tests/unit/command/test_experiments.py | 47 ++++ tests/unit/scm/test_git.py | 14 +- 15 files changed, 830 insertions(+), 44 deletions(-) create mode 100644 dvc/repo/experiments/ls.py create mode 100644 dvc/repo/experiments/pull.py create mode 100644 dvc/repo/experiments/push.py create mode 100644 tests/func/experiments/test_remote.py diff --git a/dvc/command/experiments.py b/dvc/command/experiments.py index 1860cf261..b1cc2691d 100644 --- a/dvc/command/experiments.py +++ b/dvc/command/experiments.py @@ -524,6 +524,68 @@ class CmdExperimentsBranch(CmdBase): return 0 +class CmdExperimentsList(CmdBase): + def run(self): + + exps = self.repo.experiments.ls( + rev=self.args.rev, + git_remote=self.args.git_remote, + all_=self.args.all, + ) + for baseline in exps: + tag = self.repo.scm.describe(baseline) + if not tag: + branch = self.repo.scm.describe(baseline, base="refs/heads") + if branch: + tag = branch.split("/")[-1] + name = tag if tag else baseline[:7] + logger.info(f"{name}:") + for exp_name in exps[baseline]: + logger.info(f"\t{exp_name}") + + return 0 + + +class CmdExperimentsPush(CmdBase): + def run(self): + + self.repo.experiments.push( + self.args.git_remote, self.args.experiment, force=self.args.force + ) + + logger.info( + ( + "Pushed experiment '%s' to Git remote '%s'. " + "To push cache for this experiment to a DVC remote run:\n\n" + "\tdvc push ..." + ), + self.args.experiment, + self.args.git_remote, + ) + + return 0 + + +class CmdExperimentsPull(CmdBase): + def run(self): + + self.repo.experiments.pull( + self.args.git_remote, self.args.experiment, force=self.args.force + ) + + logger.info( + ( + "Pulled experiment '%s' from Git remote '%s'. " + "To pull cache for this experiment from a DVC remote run:\n\n" + "\tdvc pull ..." + ), + self.args.experiment, + self.args.git_remote, + ) + + return 0 + + def add_parser(subparsers, parent_parser): EXPERIMENTS_HELP = "Commands to run and compare experiments." @@ -842,6 +904,88 @@ def add_parser(subparsers, parent_parser): ) experiments_branch_parser.set_defaults(func=CmdExperimentsBranch) + EXPERIMENTS_LIST_HELP = "List local and remote experiments." + experiments_list_parser = experiments_subparsers.add_parser( + "list", + parents=[parent_parser], + description=append_doc_link(EXPERIMENTS_LIST_HELP, "experiments/list"), + help=EXPERIMENTS_LIST_HELP, + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + experiments_list_parser.add_argument( + "--rev", + type=str, + default=None, + help=( + "List experiments derived from the specified revision. " + "Defaults to HEAD if neither `--rev` nor `--all` are specified." + ), + metavar="", + ) + experiments_list_parser.add_argument( + "--all", action="store_true", help="List all experiments.", + ) + experiments_list_parser.add_argument( + "git_remote", + nargs="?", + default=None, + help=( + "Optional Git remote name or Git URL. If provided, experiments " + "from the specified Git repository will be listed instead of " + "local experiments." + ), + metavar="[]", + ) + experiments_list_parser.set_defaults(func=CmdExperimentsList) + + EXPERIMENTS_PUSH_HELP = "Push a local experiment to a Git remote." + experiments_push_parser = experiments_subparsers.add_parser( + "push", + parents=[parent_parser], + description=append_doc_link(EXPERIMENTS_PUSH_HELP, "experiments/push"), + help=EXPERIMENTS_PUSH_HELP, + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + experiments_push_parser.add_argument( + "-f", + "--force", + action="store_true", + help="Replace experiment in the remote if it already exists.", + ) + experiments_push_parser.add_argument( + "git_remote", + help="Git remote name or Git URL.", + metavar="", + ) + experiments_push_parser.add_argument( + "experiment", help="Experiment to push.", metavar="", + ) + experiments_push_parser.set_defaults(func=CmdExperimentsPush) + + EXPERIMENTS_PULL_HELP = "Pull an experiment from a Git remote." + experiments_pull_parser = experiments_subparsers.add_parser( + "pull", + parents=[parent_parser], + description=append_doc_link(EXPERIMENTS_PULL_HELP, "experiments/pull"), + help=EXPERIMENTS_PULL_HELP, + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + experiments_pull_parser.add_argument( + "-f", + "--force", + action="store_true", + help="Replace local experiment already exists.", + ) + experiments_pull_parser.add_argument( + "git_remote", + help="Git remote name or Git URL.", + metavar="", + ) + experiments_pull_parser.add_argument( + "experiment", help="Experiment to pull.", metavar="", + ) + experiments_pull_parser.set_defaults(func=CmdExperimentsPull) + def _add_run_common(parser): """Add common args for 'exp run' and 'exp resume'.""" diff --git a/dvc/repo/experiments/__init__.py b/dvc/repo/experiments/__init__.py index 2e5030101..537e0b4bc 100644 --- a/dvc/repo/experiments/__init__.py +++ b/dvc/repo/experiments/__init__.py @@ -652,3 +652,18 @@ class Experiments: from dvc.repo.experiments.gc import gc return gc(self.repo, *args, **kwargs) + + def push(self, *args, **kwargs): + from dvc.repo.experiments.push import push + + return push(self.repo, *args, **kwargs) + + def pull(self, *args, **kwargs): + from dvc.repo.experiments.pull import pull + + return pull(self.repo, *args, **kwargs) + + def ls(self, *args, **kwargs): + from dvc.repo.experiments.ls import ls + + return ls(self.repo, *args, **kwargs) diff --git a/dvc/repo/experiments/executor.py b/dvc/repo/experiments/executor.py index 713c5e417..25674d9e2 100644 --- a/dvc/repo/experiments/executor.py +++ b/dvc/repo/experiments/executor.py @@ -152,21 +152,18 @@ class BaseExecutor: if not ref.startswith(EXEC_NAMESPACE) and ref != EXPS_STASH: refs.append(ref) - def on_diverged_ref(orig_ref, new_rev): + def on_diverged_ref(orig_ref: str, new_rev: str): orig_rev = dest_scm.get_ref(orig_ref) if dest_scm.diff(orig_rev, new_rev): if force: logger.debug( - "Replacing existing experiment '%s'", - os.fsdecode(orig_ref), + "Replacing existing experiment '%s'", orig_ref, ) return True if on_diverged: checkpoint = self.scm.get_ref(EXEC_CHECKPOINT) is not None on_diverged(orig_ref, checkpoint) - logger.debug( - "Reproduced existing experiment '%s'", os.fsdecode(orig_ref) - ) + logger.debug("Reproduced existing experiment '%s'", orig_ref) return False # fetch experiments diff --git a/dvc/repo/experiments/ls.py b/dvc/repo/experiments/ls.py new file mode 100644 index 000000000..812a39d6f --- /dev/null +++ b/dvc/repo/experiments/ls.py @@ -0,0 +1,50 @@ +import logging +from collections import defaultdict + +from dvc.repo import locked +from dvc.repo.scm_context import scm_context +from dvc.scm.base import RevError + +from .utils import ( + exp_refs, + exp_refs_by_baseline, + remote_exp_refs, + remote_exp_refs_by_baseline, +) + +logger = logging.getLogger(__name__) + + +@locked +@scm_context +def ls(repo, *args, rev=None, git_remote=None, all_=False, **kwargs): + from dvc.scm.git import Git + + if rev: + try: + rev = repo.scm.resolve_rev(rev) + except RevError: + if not (git_remote and Git.is_sha(rev)): + # This could be a remote rev that has not been fetched yet + raise + elif not all_: + rev = repo.scm.get_rev() + + results = defaultdict(list) + + if rev: + if git_remote: + gen = remote_exp_refs_by_baseline(repo.scm, git_remote, rev) + else: + gen = exp_refs_by_baseline(repo.scm, rev) + for info in gen: + results[rev].append(info.name) + elif all_: + if git_remote: + gen = remote_exp_refs(repo.scm, git_remote) + else: + gen = exp_refs(repo.scm) + for info in gen: + results[info.baseline_sha].append(info.name) + + return results diff --git a/dvc/repo/experiments/pull.py b/dvc/repo/experiments/pull.py new file mode 100644 index 000000000..231110bf7 --- /dev/null +++ b/dvc/repo/experiments/pull.py @@ -0,0 +1,57 @@ +import logging + +from dvc.exceptions import DvcException, InvalidArgumentError +from dvc.repo import locked +from dvc.repo.scm_context import scm_context + +from .utils import remote_exp_refs_by_name + +logger = logging.getLogger(__name__) + + +@locked +@scm_context +def pull(repo, git_remote, exp_name, *args, force=False, **kwargs): + exp_ref = _get_exp_ref(repo, git_remote, exp_name) + + def on_diverged(refname: str, rev: str) -> bool: + if repo.scm.get_ref(refname) == rev: + return True + raise DvcException( + f"Local experiment '{exp_name}' has diverged from remote " + "experiment with the same name. To override the local experiment " + "re-run with '--force'." + ) + + refspec = f"{exp_ref}:{exp_ref}" + logger.debug("Pull '%s' -> '%s'", git_remote, refspec) + repo.scm.fetch_refspecs( + git_remote, [refspec], force=force, on_diverged=on_diverged + ) + + +def _get_exp_ref(repo, git_remote, exp_name): + if exp_name.startswith("refs/"): + return exp_name + + exp_refs = list(remote_exp_refs_by_name(repo.scm, git_remote, exp_name)) + if not exp_refs: + raise InvalidArgumentError( + f"Experiment '{exp_name}' does not exist in '{git_remote}'" + ) + if len(exp_refs) > 1: + cur_rev = repo.scm.get_rev() + for info in exp_refs: + if info.baseline_sha == cur_rev: + return info + msg = [ + ( + f"Ambiguous name '{exp_name}' refers to multiple " + "experiments in '{git_remote}'. Use full refname to pull one " + "of the following:" + ), + "", + ] + msg.extend([f"\t{info}" for info in exp_refs]) + raise InvalidArgumentError("\n".join(msg)) + return exp_refs[0] diff --git a/dvc/repo/experiments/push.py b/dvc/repo/experiments/push.py new file mode 100644 index 000000000..3e2ce56b7 --- /dev/null +++ b/dvc/repo/experiments/push.py @@ -0,0 +1,57 @@ +import logging + +from dvc.exceptions import DvcException, InvalidArgumentError +from dvc.repo import locked +from dvc.repo.scm_context import scm_context + +from .utils import exp_refs_by_name + +logger = logging.getLogger(__name__) + + +@locked +@scm_context +def push(repo, git_remote, exp_name, *args, force=False, **kwargs): + exp_ref = _get_exp_ref(repo, exp_name) + + def on_diverged(refname: str, rev: str) -> bool: + if repo.scm.get_ref(refname) == rev: + return True + raise DvcException( + f"Local experiment '{exp_name}' has diverged from remote " + "experiment with the same name. To override the remote experiment " + "re-run with '--force'." + ) + + refname = str(exp_ref) + logger.debug("Push '%s' -> '%s'", exp_ref, git_remote) + repo.scm.push_refspec( + git_remote, refname, refname, force=force, on_diverged=on_diverged + ) + + +def _get_exp_ref(repo, exp_name): + if exp_name.startswith("refs/"): + return exp_name + + exp_refs = list(exp_refs_by_name(repo.scm, exp_name)) + if not exp_refs: + raise InvalidArgumentError( + f"'{exp_name}' is not a valid experiment name" + ) + if len(exp_refs) > 1: + cur_rev = repo.scm.get_rev() + for info in exp_refs: + if info.baseline_sha == cur_rev: + return info + msg = [ + ( + f"Ambiguous name '{exp_name}' refers to multiple " + "experiments. Use full refname to push one of the " + "following:" + ), + "", + ] + msg.extend([f"\t{info}" for info in exp_refs]) + raise InvalidArgumentError("\n".join(msg)) + return exp_refs[0] diff --git a/dvc/repo/experiments/utils.py b/dvc/repo/experiments/utils.py index c8041785e..c743b9b0f 100644 --- a/dvc/repo/experiments/utils.py +++ b/dvc/repo/experiments/utils.py @@ -30,3 +30,44 @@ def exp_refs_by_name( for ref_info in exp_refs(scm): if ref_info.name == name: yield ref_info + + +def exp_refs_by_baseline( + scm: "Git", rev: str +) -> Generator["ExpRefInfo", None, None]: + """Iterate over all experiment refs with the specified baseline.""" + ref_info = ExpRefInfo(baseline_sha=rev) + for ref in scm.iter_refs(base=str(ref_info)): + if ref.startswith(EXEC_NAMESPACE) or ref == EXPS_STASH: + continue + yield ExpRefInfo.from_ref(ref) + + +def remote_exp_refs( + scm: "Git", url: str +) -> Generator["ExpRefInfo", None, None]: + """Iterate over all remote experiment refs.""" + for ref in scm.iter_remote_refs(url, base=EXPS_NAMESPACE): + if ref.startswith(EXEC_NAMESPACE) or ref == EXPS_STASH: + continue + yield ExpRefInfo.from_ref(ref) + + +def remote_exp_refs_by_name( + scm: "Git", url: str, name: str +) -> Generator["ExpRefInfo", None, None]: + """Iterate over all remote experiment refs matching the specified name.""" + for ref_info in remote_exp_refs(scm, url): + if ref_info.name == name: + yield ref_info + + +def remote_exp_refs_by_baseline( + scm: "Git", url: str, rev: str +) -> Generator["ExpRefInfo", None, None]: + """Iterate over all remote experiment refs with the specified baseline.""" + ref_info = ExpRefInfo(baseline_sha=rev) + for ref in scm.iter_remote_refs(url, base=str(ref_info)): + if ref.startswith(EXEC_NAMESPACE) or ref == EXPS_STASH: + continue + yield ExpRefInfo.from_ref(ref) diff --git a/dvc/scm/git/__init__.py b/dvc/scm/git/__init__.py index 62b990ea0..27472e3bf 100644 --- a/dvc/scm/git/__init__.py +++ b/dvc/scm/git/__init__.py @@ -321,6 +321,7 @@ class Git(Base): get_ref = partialmethod(_backend_func, "get_ref") remove_ref = partialmethod(_backend_func, "remove_ref") iter_refs = partialmethod(_backend_func, "iter_refs") + iter_remote_refs = partialmethod(_backend_func, "iter_remote_refs") get_refs_containing = partialmethod(_backend_func, "get_refs_containing") push_refspec = partialmethod(_backend_func, "push_refspec") fetch_refspecs = partialmethod(_backend_func, "fetch_refspecs") diff --git a/dvc/scm/git/backend/base.py b/dvc/scm/git/backend/base.py index 6bb4b1abf..5b1d8a03c 100644 --- a/dvc/scm/git/backend/base.py +++ b/dvc/scm/git/backend/base.py @@ -174,22 +174,42 @@ class BaseGitBackend(ABC): If base is specified, only refs which begin with base will be yielded. """ + @abstractmethod + def iter_remote_refs(self, url: str, base: Optional[str] = None): + """Iterate over all refs in the specified remote Git repo. + + If base is specified, only refs which begin with base will be yielded. + URL can be a named Git remote or URL. + """ + @abstractmethod def get_refs_containing(self, rev: str, pattern: Optional[str] = None): """Iterate over all git refs containing the specfied revision.""" @abstractmethod - def push_refspec(self, url: str, src: Optional[str], dest: str): + def push_refspec( + self, + url: str, + src: Optional[str], + dest: str, + force: bool = False, + on_diverged: Optional[Callable[[str, str], bool]] = None, + ): """Push refspec to a remote Git repo. Args: - url: Remote repo Git URL (Note this must be a Git URL and not - a remote name). + url: Git remote name or absolute Git URL. src: Local refspec. If src ends with "/" it will be treated as a prefix, and all refs inside src will be pushed using dest as destination refspec prefix. If src is None, dest will be deleted from the remote. dest: Remote refspec. + force: If True, remote refs will be overwritten. + on_diverged: Callback function which will be called if local ref + and remote have diverged and force is False. If the callback + returns True the remote ref will be overwritten. + Callback will be of the form: + on_diverged(local_refname, remote_sha) """ @abstractmethod @@ -198,7 +218,7 @@ class BaseGitBackend(ABC): url: str, refspecs: Iterable[str], force: Optional[bool] = False, - on_diverged: Optional[Callable[[bytes, bytes], bool]] = None, + on_diverged: Optional[Callable[[str, str], bool]] = None, ): """Fetch refspecs from a remote Git repo. @@ -211,6 +231,8 @@ class BaseGitBackend(ABC): on_diverged: Callback function which will be called if local ref and remote have diverged and force is False. If the callback returns True the local ref will be overwritten. + Callback will be of the form: + on_diverged(local_refname, remote_sha) """ @abstractmethod diff --git a/dvc/scm/git/backend/dulwich.py b/dvc/scm/git/backend/dulwich.py index 6c2635894..c8bc9256f 100644 --- a/dvc/scm/git/backend/dulwich.py +++ b/dvc/scm/git/backend/dulwich.py @@ -185,11 +185,92 @@ class DulwichBackend(BaseGitBackend): # pylint:disable=abstract-method else: yield os.fsdecode(key) + def iter_remote_refs(self, url: str, base: Optional[str] = None): + from dulwich.client import get_transport_and_path + from dulwich.porcelain import get_remote_repo + + try: + _remote, location = get_remote_repo(self.repo, url) + client, path = get_transport_and_path(location) + except Exception as exc: + raise SCMError( + f"'{url}' is not a valid Git remote or URL" + ) from exc + + if base: + yield from ( + os.fsdecode(ref) + for ref in client.get_refs(path) + if ref.startswith(os.fsencode(base)) + ) + else: + yield from (os.fsdecode(ref) for ref in client.get_refs(path)) + def get_refs_containing(self, rev: str, pattern: Optional[str] = None): raise NotImplementedError - def push_refspec(self, url: str, src: Optional[str], dest: str): + def push_refspec( + self, + url: str, + src: Optional[str], + dest: str, + force: bool = False, + on_diverged: Optional[Callable[[str, str], bool]] = None, + ): from dulwich.client import get_transport_and_path + from dulwich.errors import NotGitRepository, SendPackError + from dulwich.porcelain import ( + DivergedBranches, + check_diverged, + get_remote_repo, + ) + + dest_refs, values = self._push_dest_refs(src, dest) + + try: + _remote, location = get_remote_repo(self.repo, url) + client, path = get_transport_and_path(location) + except Exception as exc: + raise SCMError( + f"'{url}' is not a valid Git remote or URL" + ) from exc + + def update_refs(refs): + new_refs = {} + for ref, value in zip(dest_refs, values): + if ref in refs: + local_sha = self.repo.refs[ref] + remote_sha = refs[ref] + try: + check_diverged(self.repo, remote_sha, local_sha) + except DivergedBranches: + if not force: + overwrite = False + if on_diverged: + overwrite = on_diverged( + os.fsdecode(ref), os.fsdecode(remote_sha), + ) + if not overwrite: + continue + new_refs[ref] = value + return new_refs + + def progress(msg): + logger.trace("git send_pack: %s", msg) + + try: + client.send_pack( + path, + update_refs, + self.repo.object_store.generate_pack_data, + progress=progress, + ) + except (NotGitRepository, SendPackError) as exc: + raise SCMError("Git failed to push '{src}' to '{url}'") from exc + + def _push_dest_refs( + self, src: str, dest: str + ) -> Tuple[Iterable[bytes], Iterable[bytes]]: from dulwich.objects import ZERO_SHA if src is not None and src.endswith("/"): @@ -203,37 +284,22 @@ class DulwichBackend(BaseGitBackend): # pylint:disable=abstract-method else: values = [self.repo.refs[os.fsencode(src)]] dest_refs = [os.fsencode(dest)] - - def update_refs(refs): - for ref, value in zip(dest_refs, values): - refs[ref] = value - return refs - - try: - client, path = get_transport_and_path(url) - except Exception as exc: - raise SCMError("Could not get remote client") from exc - - def progress(msg): - logger.trace("git send_pack: %s", msg) - - client.send_pack( - path, - update_refs, - self.repo.object_store.generate_pack_data, - progress=progress, - ) + return dest_refs, values def fetch_refspecs( self, url: str, refspecs: Iterable[str], force: Optional[bool] = False, - on_diverged: Optional[Callable[[bytes, bytes], bool]] = None, + on_diverged: Optional[Callable[[str, str], bool]] = None, ): from dulwich.client import get_transport_and_path from dulwich.objectspec import parse_reftuples - from dulwich.porcelain import DivergedBranches, check_diverged + from dulwich.porcelain import ( + DivergedBranches, + check_diverged, + get_remote_repo, + ) fetch_refs = [] @@ -253,10 +319,12 @@ class DulwichBackend(BaseGitBackend): # pylint:disable=abstract-method ] try: - client, path = get_transport_and_path(url) + _remote, location = get_remote_repo(self.repo, url) + client, path = get_transport_and_path(location) except Exception as exc: - - raise SCMError("Could not get remote client") from exc + raise SCMError( + f"'{url}' is not a valid Git remote or URL" + ) from exc def progress(msg): logger.trace("git fetch: %s", msg) diff --git a/dvc/scm/git/backend/gitpython.py b/dvc/scm/git/backend/gitpython.py index 403ef26c7..4148c7feb 100644 --- a/dvc/scm/git/backend/gitpython.py +++ b/dvc/scm/git/backend/gitpython.py @@ -362,6 +362,9 @@ class GitPythonBackend(BaseGitBackend): # pylint:disable=abstract-method base = base[:-1] yield "/".join([base, ref.name]) + def iter_remote_refs(self, url: str, base: Optional[str] = None): + raise NotImplementedError + def get_refs_containing(self, rev: str, pattern: Optional[str] = None): from git.exc import GitCommandError @@ -379,7 +382,14 @@ class GitPythonBackend(BaseGitBackend): # pylint:disable=abstract-method except GitCommandError: pass - def push_refspec(self, url: str, src: Optional[str], dest: str): + def push_refspec( + self, + url: str, + src: Optional[str], + dest: str, + force: bool = False, + on_diverged: Optional[Callable[[str, str], bool]] = None, + ): raise NotImplementedError def fetch_refspecs( @@ -387,7 +397,7 @@ class GitPythonBackend(BaseGitBackend): # pylint:disable=abstract-method url: str, refspecs: Iterable[str], force: Optional[bool] = False, - on_diverged: Optional[Callable[[bytes, bytes], bool]] = None, + on_diverged: Optional[Callable[[str, str], bool]] = None, ): raise NotImplementedError diff --git a/tests/func/experiments/test_experiments.py b/tests/func/experiments/test_experiments.py index 3036cddc5..160add628 100644 --- a/tests/func/experiments/test_experiments.py +++ b/tests/func/experiments/test_experiments.py @@ -5,14 +5,13 @@ import stat import pytest from funcy import first +from dvc.repo.experiments.utils import exp_refs_by_rev from dvc.utils.serialize import PythonFileCorruptedError from tests.func.test_repro_multistage import COPY_SCRIPT @pytest.mark.parametrize("name", [None, "foo"]) def test_new_simple(tmp_dir, scm, dvc, exp_stage, mocker, name): - from dvc.repo.experiments.utils import exp_refs_by_rev - baseline = scm.get_rev() tmp_dir.gen("params.yaml", "foo: 2") @@ -397,3 +396,35 @@ def test_packed_args_exists(tmp_dir, scm, dvc, exp_stage, caplog): with caplog.at_level(logging.WARNING): dvc.experiments.run(exp_stage.addressing) assert "Temporary DVC file" in caplog.text + + +def test_list(tmp_dir, scm, dvc, exp_stage): + baseline_a = scm.get_rev() + results = dvc.experiments.run(exp_stage.addressing, params=["foo=2"]) + exp_a = first(results) + ref_info_a = first(exp_refs_by_rev(scm, exp_a)) + + results = dvc.experiments.run(exp_stage.addressing, params=["foo=3"]) + exp_b = first(results) + ref_info_b = first(exp_refs_by_rev(scm, exp_b)) + + tmp_dir.scm_gen("new", "new", commit="new") + baseline_c = scm.get_rev() + results = dvc.experiments.run(exp_stage.addressing, params=["foo=4"]) + exp_c = first(results) + ref_info_c = first(exp_refs_by_rev(scm, exp_c)) + + assert dvc.experiments.ls() == { + baseline_c: [ref_info_c.name], + } + + exp_list = dvc.experiments.ls(rev=ref_info_a.baseline_sha) + assert {key: set(val) for key, val in exp_list.items()} == { + baseline_a: {ref_info_a.name, ref_info_b.name} + } + + exp_list = dvc.experiments.ls(all_=True) + assert {key: set(val) for key, val in exp_list.items()} == { + baseline_a: {ref_info_a.name, ref_info_b.name}, + baseline_c: {ref_info_c.name}, + } diff --git a/tests/func/experiments/test_remote.py b/tests/func/experiments/test_remote.py new file mode 100644 index 000000000..3f214f401 --- /dev/null +++ b/tests/func/experiments/test_remote.py @@ -0,0 +1,240 @@ +import pytest +from funcy import first + +from dvc.exceptions import DvcException +from dvc.repo.experiments.utils import exp_refs_by_rev + + +@pytest.fixture +def git_upstream(tmp_dir, erepo_dir): + url = "file://{}".format(erepo_dir.resolve().as_posix()) + tmp_dir.scm.gitpython.repo.create_remote("upstream", url) + erepo_dir.remote = "upstream" + erepo_dir.url = url + return erepo_dir + + +@pytest.fixture +def git_downstream(tmp_dir, erepo_dir): + url = "file://{}".format(tmp_dir.resolve().as_posix()) + erepo_dir.scm.gitpython.repo.create_remote("upstream", url) + erepo_dir.remote = "upstream" + erepo_dir.url = url + return erepo_dir + + +@pytest.mark.parametrize("use_url", [True, False]) +def test_push(tmp_dir, scm, dvc, git_upstream, exp_stage, use_url): + from dvc.exceptions import InvalidArgumentError + + remote = git_upstream.url if use_url else git_upstream.remote + with pytest.raises(InvalidArgumentError): + dvc.experiments.push(remote, "foo") + + results = dvc.experiments.run(exp_stage.addressing, params=["foo=2"]) + exp = first(results) + ref_info = first(exp_refs_by_rev(scm, exp)) + + dvc.experiments.push(remote, ref_info.name) + assert git_upstream.scm.get_ref(str(ref_info)) == exp + + git_upstream.scm.remove_ref(str(ref_info)) + + dvc.experiments.push(remote, str(ref_info)) + assert git_upstream.scm.get_ref(str(ref_info)) == exp + + +def test_push_diverged(tmp_dir, scm, dvc, git_upstream, exp_stage): + git_upstream.scm_gen("foo", "foo", commit="init") + remote_rev = git_upstream.scm.get_rev() + + results = dvc.experiments.run(exp_stage.addressing, params=["foo=2"]) + exp = first(results) + ref_info = first(exp_refs_by_rev(scm, exp)) + + git_upstream.scm.set_ref(str(ref_info), remote_rev) + + with pytest.raises(DvcException): + dvc.experiments.push(git_upstream.remote, ref_info.name) + assert git_upstream.scm.get_ref(str(ref_info)) == remote_rev + + dvc.experiments.push(git_upstream.remote, ref_info.name, force=True) + assert git_upstream.scm.get_ref(str(ref_info)) == exp + + +def test_push_checkpoint(tmp_dir, scm, dvc, git_upstream, checkpoint_stage): + results = dvc.experiments.run( + checkpoint_stage.addressing, params=["foo=2"] + ) + exp_a = first(results) + ref_info_a = first(exp_refs_by_rev(scm, exp_a)) + + dvc.experiments.push(git_upstream.remote, ref_info_a.name, force=True) + assert git_upstream.scm.get_ref(str(ref_info_a)) == exp_a + + results = dvc.experiments.run( + checkpoint_stage.addressing, checkpoint_resume=exp_a + ) + exp_b = first(results) + ref_info_b = first(exp_refs_by_rev(scm, exp_b)) + + tmp_dir.scm_gen("new", "new", commit="new") + + dvc.experiments.push(git_upstream.remote, ref_info_b.name, force=True) + assert git_upstream.scm.get_ref(str(ref_info_b)) == exp_b + + +def test_push_ambiguous_name(tmp_dir, scm, dvc, git_upstream, exp_stage): + from dvc.exceptions import InvalidArgumentError + + remote = git_upstream.remote + + results = dvc.experiments.run( + exp_stage.addressing, params=["foo=2"], name="foo" + ) + exp_a = first(results) + ref_info_a = first(exp_refs_by_rev(scm, exp_a)) + + tmp_dir.scm_gen("new", "new", commit="new") + results = dvc.experiments.run( + exp_stage.addressing, params=["foo=3"], name="foo" + ) + exp_b = first(results) + ref_info_b = first(exp_refs_by_rev(scm, exp_b)) + + dvc.experiments.push(remote, "foo") + assert git_upstream.scm.get_ref(str(ref_info_b)) == exp_b + + tmp_dir.scm_gen("new", "new 2", commit="new 2") + + with pytest.raises(InvalidArgumentError): + dvc.experiments.push(remote, "foo") + + dvc.experiments.push(remote, str(ref_info_a)) + assert git_upstream.scm.get_ref(str(ref_info_a)) == exp_a + + +@pytest.mark.parametrize("use_url", [True, False]) +def test_list_remote(tmp_dir, scm, dvc, git_downstream, exp_stage, use_url): + baseline_a = scm.get_rev() + results = dvc.experiments.run(exp_stage.addressing, params=["foo=2"]) + exp_a = first(results) + ref_info_a = first(exp_refs_by_rev(scm, exp_a)) + + results = dvc.experiments.run(exp_stage.addressing, params=["foo=3"]) + exp_b = first(results) + ref_info_b = first(exp_refs_by_rev(scm, exp_b)) + + tmp_dir.scm_gen("new", "new", commit="new") + baseline_c = scm.get_rev() + results = dvc.experiments.run(exp_stage.addressing, params=["foo=4"]) + exp_c = first(results) + ref_info_c = first(exp_refs_by_rev(scm, exp_c)) + + remote = git_downstream.url if use_url else git_downstream.remote + + assert git_downstream.scm.get_ref("HEAD") != scm.get_ref("HEAD") + downstream_exp = git_downstream.dvc.experiments + assert downstream_exp.ls(git_remote=remote) == {} + + exp_list = downstream_exp.ls(rev=baseline_a, git_remote=remote) + assert {key: set(val) for key, val in exp_list.items()} == { + baseline_a: {ref_info_a.name, ref_info_b.name} + } + + exp_list = downstream_exp.ls(all_=True, git_remote=remote) + assert {key: set(val) for key, val in exp_list.items()} == { + baseline_a: {ref_info_a.name, ref_info_b.name}, + baseline_c: {ref_info_c.name}, + } + + +@pytest.mark.parametrize("use_url", [True, False]) +def test_pull(tmp_dir, scm, dvc, git_downstream, exp_stage, use_url): + from dvc.exceptions import InvalidArgumentError + + remote = git_downstream.url if use_url else git_downstream.remote + downstream_exp = git_downstream.dvc.experiments + with pytest.raises(InvalidArgumentError): + downstream_exp.pull(remote, "foo") + + results = dvc.experiments.run(exp_stage.addressing, params=["foo=2"]) + exp = first(results) + ref_info = first(exp_refs_by_rev(scm, exp)) + + downstream_exp.pull(remote, ref_info.name) + assert git_downstream.scm.get_ref(str(ref_info)) == exp + + git_downstream.scm.remove_ref(str(ref_info)) + + downstream_exp.pull(remote, str(ref_info)) + assert git_downstream.scm.get_ref(str(ref_info)) == exp + + +def test_pull_diverged(tmp_dir, scm, dvc, git_downstream, exp_stage): + git_downstream.scm_gen("foo", "foo", commit="init") + remote_rev = git_downstream.scm.get_rev() + + results = dvc.experiments.run(exp_stage.addressing, params=["foo=2"]) + exp = first(results) + ref_info = first(exp_refs_by_rev(scm, exp)) + + git_downstream.scm.set_ref(str(ref_info), remote_rev) + + downstream_exp = git_downstream.dvc.experiments + with pytest.raises(DvcException): + downstream_exp.pull(git_downstream.remote, ref_info.name) + assert git_downstream.scm.get_ref(str(ref_info)) == remote_rev + + downstream_exp.pull(git_downstream.remote, ref_info.name, force=True) + assert git_downstream.scm.get_ref(str(ref_info)) == exp + + +def test_pull_checkpoint(tmp_dir, scm, dvc, git_downstream, checkpoint_stage): + results = dvc.experiments.run( + checkpoint_stage.addressing, params=["foo=2"] + ) + exp_a = first(results) + ref_info_a = first(exp_refs_by_rev(scm, exp_a)) + + downstream_exp = git_downstream.dvc.experiments + downstream_exp.pull(git_downstream.remote, ref_info_a.name, force=True) + assert git_downstream.scm.get_ref(str(ref_info_a)) == exp_a + + results = dvc.experiments.run( + checkpoint_stage.addressing, checkpoint_resume=exp_a + ) + exp_b = first(results) + ref_info_b = first(exp_refs_by_rev(scm, exp_b)) + + downstream_exp.pull(git_downstream.remote, ref_info_b.name, force=True) + assert git_downstream.scm.get_ref(str(ref_info_b)) == exp_b + + +def test_pull_ambiguous_name(tmp_dir, scm, dvc, git_downstream, exp_stage): + from dvc.exceptions import InvalidArgumentError + + results = dvc.experiments.run( + exp_stage.addressing, params=["foo=2"], name="foo" + ) + exp_a = first(results) + ref_info_a = first(exp_refs_by_rev(scm, exp_a)) + + tmp_dir.scm_gen("new", "new", commit="new") + results = dvc.experiments.run( + exp_stage.addressing, params=["foo=3"], name="foo" + ) + exp_b = first(results) + ref_info_b = first(exp_refs_by_rev(scm, exp_b)) + + remote = git_downstream.remote + downstream_exp = git_downstream.dvc.experiments + with pytest.raises(InvalidArgumentError): + downstream_exp.pull(remote, "foo") + + downstream_exp.pull(remote, str(ref_info_b)) + assert git_downstream.scm.get_ref(str(ref_info_b)) == exp_b + + with git_downstream.scm.detach_head(ref_info_a.baseline_sha): + downstream_exp.pull(remote, "foo") + assert git_downstream.scm.get_ref(str(ref_info_a)) == exp_a diff --git a/tests/unit/command/test_experiments.py b/tests/unit/command/test_experiments.py index a2d00e2bc..c36a43af4 100644 --- a/tests/unit/command/test_experiments.py +++ b/tests/unit/command/test_experiments.py @@ -6,6 +6,9 @@ from dvc.command.experiments import ( CmdExperimentsBranch, CmdExperimentsDiff, CmdExperimentsGC, + CmdExperimentsList, + CmdExperimentsPull, + CmdExperimentsPush, CmdExperimentsRun, CmdExperimentsShow, ) @@ -150,3 +153,47 @@ def test_experiments_branch(dvc, scm, mocker): assert cmd.run() == 0 m.assert_called_once_with(cmd.repo, "expname", "branchname") + + +def test_experiments_list(dvc, scm, mocker): + cli_args = parse_args( + ["experiments", "list", "origin", "--rev", "foo", "--all"] + ) + assert cli_args.func == CmdExperimentsList + + cmd = cli_args.func(cli_args) + m = mocker.patch("dvc.repo.experiments.ls.ls", return_value={}) + + assert cmd.run() == 0 + + m.assert_called_once_with( + cmd.repo, git_remote="origin", rev="foo", all_=True + ) + + +def test_experiments_push(dvc, scm, mocker): + cli_args = parse_args( + ["experiments", "push", "origin", "experiment", "--force"] + ) + assert cli_args.func == CmdExperimentsPush + + cmd = cli_args.func(cli_args) + m = mocker.patch("dvc.repo.experiments.push.push", return_value={}) + + assert cmd.run() == 0 + + m.assert_called_once_with(cmd.repo, "origin", "experiment", force=True) + + +def test_experiments_pull(dvc, scm, mocker): + cli_args = parse_args( + ["experiments", "pull", "origin", "experiment", "--force"] + ) + assert cli_args.func == CmdExperimentsPull + + cmd = cli_args.func(cli_args) + m = mocker.patch("dvc.repo.experiments.pull.pull", return_value={}) + + assert cmd.run() == 0 + + m.assert_called_once_with(cmd.repo, "origin", "experiment", force=True) diff --git a/tests/unit/scm/test_git.py b/tests/unit/scm/test_git.py index e834dcfd1..af94fd573 100644 --- a/tests/unit/scm/test_git.py +++ b/tests/unit/scm/test_git.py @@ -211,7 +211,8 @@ def test_refs_containing(tmp_dir, scm): assert expected == set(scm.get_refs_containing(init_rev)) -def test_push_refspec(tmp_dir, scm, make_tmp_dir): +@pytest.mark.parametrize("use_url", [True, False]) +def test_push_refspec(tmp_dir, scm, make_tmp_dir, use_url): tmp_dir.scm_gen({"file": "0"}, commit="init") init_rev = scm.get_rev() tmp_dir.gen( @@ -222,18 +223,23 @@ def test_push_refspec(tmp_dir, scm, make_tmp_dir): ) remote_dir = make_tmp_dir("git-remote", scm=True) url = "file://{}".format(remote_dir.resolve().as_posix()) + scm.gitpython.repo.create_remote("origin", url) - scm.push_refspec(url, "refs/foo/bar", "refs/foo/bar") + with pytest.raises(SCMError): + scm.push_refspec("bad-remote", "refs/foo/bar", "refs/foo/bar") + + remote = url if use_url else "origin" + scm.push_refspec(remote, "refs/foo/bar", "refs/foo/bar") assert init_rev == remote_dir.scm.get_ref("refs/foo/bar") remote_dir.scm.checkout("refs/foo/bar") assert init_rev == remote_dir.scm.get_rev() assert "0" == (remote_dir / "file").read_text() - scm.push_refspec(url, "refs/foo/", "refs/foo/") + scm.push_refspec(remote, "refs/foo/", "refs/foo/") assert init_rev == remote_dir.scm.get_ref("refs/foo/baz") - scm.push_refspec(url, None, "refs/foo/baz") + scm.push_refspec(remote, None, "refs/foo/baz") assert remote_dir.scm.get_ref("refs/foo/baz") is None