diff --git a/dvc/command/experiments.py b/dvc/command/experiments.py index c96037921..1f74cd9f4 100644 --- a/dvc/command/experiments.py +++ b/dvc/command/experiments.py @@ -1151,9 +1151,9 @@ def add_parser(subparsers, parent_parser): 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." + "Optional Git remote name or Git URL. " + "If provided, experiments from the specified Git repository " + " will be listed instead of local ones." ), metavar="[]", ) diff --git a/dvc/scm/base.py b/dvc/scm/base.py index c4813ea27..30db684db 100644 --- a/dvc/scm/base.py +++ b/dvc/scm/base.py @@ -47,6 +47,15 @@ class InvalidRemoteSCMRepo(SCMError): super().__init__(msg) +class GitAuthError(SCMError): + def __init__(self, url: str): + super().__init__( + f"HTTP Git authentication is not supported: '{url}'" + "\nSee https://dvc.org/doc//user-guide/" + "troubleshooting#git-auth" + ) + + class Base: """Base class for source control management driver implementations.""" diff --git a/dvc/scm/git/backend/dulwich.py b/dvc/scm/git/backend/dulwich.py index 91327206b..977f842f5 100644 --- a/dvc/scm/git/backend/dulwich.py +++ b/dvc/scm/git/backend/dulwich.py @@ -20,7 +20,7 @@ from funcy import cached_property from dvc.path_info import PathInfo from dvc.progress import Tqdm -from dvc.scm.base import InvalidRemoteSCMRepo, SCMError +from dvc.scm.base import GitAuthError, InvalidRemoteSCMRepo, SCMError from dvc.utils import relpath from ..objects import GitObject @@ -355,7 +355,7 @@ class DulwichBackend(BaseGitBackend): # pylint:disable=abstract-method 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.client import HTTPUnauthorized, get_transport_and_path from dulwich.errors import NotGitRepository from dulwich.porcelain import get_remote_repo @@ -376,6 +376,8 @@ class DulwichBackend(BaseGitBackend): # pylint:disable=abstract-method yield from (os.fsdecode(ref) for ref in client.get_refs(path)) except NotGitRepository as exc: raise InvalidRemoteSCMRepo(url) from exc + except HTTPUnauthorized: + raise GitAuthError(url) def get_refs_containing(self, rev: str, pattern: Optional[str] = None): raise NotImplementedError @@ -388,7 +390,7 @@ class DulwichBackend(BaseGitBackend): # pylint:disable=abstract-method force: bool = False, on_diverged: Optional[Callable[[str, str], bool]] = None, ): - from dulwich.client import get_transport_and_path + from dulwich.client import HTTPUnauthorized, get_transport_and_path from dulwich.errors import NotGitRepository, SendPackError from dulwich.porcelain import ( DivergedBranches, @@ -447,6 +449,8 @@ class DulwichBackend(BaseGitBackend): # pylint:disable=abstract-method ) except (NotGitRepository, SendPackError) as exc: raise SCMError("Git failed to push '{src}' to '{url}'") from exc + except HTTPUnauthorized: + raise GitAuthError(url) def _push_dest_refs( self, src: Optional[str], dest: str diff --git a/tests/func/experiments/conftest.py b/tests/func/experiments/conftest.py index 455aa687c..67c025802 100644 --- a/tests/func/experiments/conftest.py +++ b/tests/func/experiments/conftest.py @@ -88,3 +88,17 @@ def checkpoint_stage(tmp_dir, scm, dvc, mocker): scm.commit("init") stage.iterations = DEFAULT_ITERATIONS return stage + + +@pytest.fixture +def http_auth_patch(mocker): + from dulwich.client import HTTPUnauthorized + + url = "https://0.0.0.0" + client = mocker.MagicMock() + client.get_refs.side_effect = HTTPUnauthorized("", url) + client.send_pack.side_effect = HTTPUnauthorized("", url) + + patch = mocker.patch("dulwich.client.get_transport_and_path") + patch.return_value = (client, url) + return url diff --git a/tests/func/experiments/test_remote.py b/tests/func/experiments/test_remote.py index dffb1970c..2e8a6cae2 100644 --- a/tests/func/experiments/test_remote.py +++ b/tests/func/experiments/test_remote.py @@ -252,3 +252,37 @@ def test_push_pull_cache( path = os.path.join(dvc.odb.local.cache_dir, hash_[:2], hash_[2:]) assert os.path.exists(path) assert open(path).read() == str(x) + + +def test_auth_error_list(tmp_dir, scm, dvc, http_auth_patch): + from dvc.scm.base import GitAuthError + + with pytest.raises( + GitAuthError, + match=f"HTTP Git authentication is not supported: '{http_auth_patch}'", + ): + dvc.experiments.ls(git_remote=http_auth_patch) + + +def test_auth_error_pull(tmp_dir, scm, dvc, http_auth_patch): + from dvc.scm.base import GitAuthError + + with pytest.raises( + GitAuthError, + match=f"HTTP Git authentication is not supported: '{http_auth_patch}'", + ): + dvc.experiments.pull(http_auth_patch, "foo") + + +def test_auth_error_push(tmp_dir, scm, dvc, exp_stage, http_auth_patch): + from dvc.scm.base import GitAuthError + + results = dvc.experiments.run(exp_stage.addressing, params=["foo=2"]) + exp = first(results) + ref_info = first(exp_refs_by_rev(scm, exp)) + + with pytest.raises( + GitAuthError, + match=f"HTTP Git authentication is not supported: '{http_auth_patch}'", + ): + dvc.experiments.push(http_auth_patch, ref_info.name)