mirror of
https://github.com/Stone-Red-Code/dvc.git
synced 2026-11-02 07:18:25 +01:00
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
This commit is contained in:
@@ -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="<rev>",
|
||||
)
|
||||
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="[<git_remote>]",
|
||||
)
|
||||
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="<git_remote>",
|
||||
)
|
||||
experiments_push_parser.add_argument(
|
||||
"experiment", help="Experiment to push.", metavar="<experiment>",
|
||||
)
|
||||
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="<git_remote>",
|
||||
)
|
||||
experiments_pull_parser.add_argument(
|
||||
"experiment", help="Experiment to pull.", metavar="<experiment>",
|
||||
)
|
||||
experiments_pull_parser.set_defaults(func=CmdExperimentsPull)
|
||||
|
||||
|
||||
def _add_run_common(parser):
|
||||
"""Add common args for 'exp run' and 'exp resume'."""
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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]
|
||||
@@ -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]
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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},
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user