diff --git a/CHANGES b/CHANGES index 50e76414..4adad80a 100644 --- a/CHANGES +++ b/CHANGES @@ -20,6 +20,12 @@ $ uv add libvcs --prerelease allow _Notes on the upcoming release will go here._ +### Fixes + +#### pytest 9.1 compatibility for the pytest plugin (#537) + +libvcs's pytest plugin (see {ref}`pytest_plugin`) loads automatically whenever pytest runs, so any project with libvcs installed can now use pytest 9.1+ — the plugin no longer aborts the test session at startup. Its Git, Mercurial, and Subversion fixtures upgrade cleanly. + ## libvcs 0.42.0 (2026-05-31) libvcs 0.42.0 teaches {class}`~libvcs.sync.git.GitSync` to clone at an arbitrary shallow depth rather than only a single commit, so tools that synchronize many repositories can persist and apply a numeric `--depth N` instead of a boolean shallow flag. It also repairs a long-standing bug where the `git_shallow` and `tls_verify` constructor arguments were silently dropped and then raised `AttributeError` on the next {meth}`~libvcs.sync.git.GitSync.obtain`. Downstream tools such as vcspull are the primary beneficiaries. diff --git a/src/libvcs/cmd/git.py b/src/libvcs/cmd/git.py index 1bdb0890..fbad217e 100644 --- a/src/libvcs/cmd/git.py +++ b/src/libvcs/cmd/git.py @@ -4094,17 +4094,19 @@ def set_head( Examples -------- - >>> GitRemoteCmd( + >>> remote = GitRemoteCmd( ... path=example_git_repo.path, ... remote_name='origin' - ... ).set_head(auto=True) - 'origin/HEAD set to master' + ... ) - >>> GitRemoteCmd( - ... path=example_git_repo.path, - ... remote_name='origin' - ... ).set_head('master') - '' + The exact message wording varies across git versions (git 2.54+ reports + "is unchanged" when HEAD already points at the branch), so assert on the + stable parts rather than the literal string: + + >>> 'master' in remote.set_head(auto=True) + True + >>> isinstance(remote.set_head('master'), str) + True """ local_flags: list[str] = [self.remote_name] diff --git a/src/libvcs/pytest_plugin.py b/src/libvcs/pytest_plugin.py index d6963c42..fe85b968 100644 --- a/src/libvcs/pytest_plugin.py +++ b/src/libvcs/pytest_plugin.py @@ -34,8 +34,8 @@ def __init__(self, attempts: int, *args: object) -> None: reason="git is not available", ) skip_if_svn_missing = pytest.mark.skipif( - not shutil.which("svn"), - reason="svn is not available", + not shutil.which("svn") or not shutil.which("svnadmin"), + reason="svn or svnadmin is not available", ) skip_if_hg_missing = pytest.mark.skipif( not shutil.which("hg"), @@ -43,6 +43,24 @@ def __init__(self, attempts: int, *args: object) -> None: ) +def _skip_if_git_missing() -> None: + """Skip the calling fixture when the ``git`` binary is unavailable.""" + if not shutil.which("git"): + pytest.skip(reason="git is not available") + + +def _skip_if_svn_missing() -> None: + """Skip the calling fixture when ``svn`` or ``svnadmin`` is unavailable.""" + if not shutil.which("svn") or not shutil.which("svnadmin"): + pytest.skip(reason="svn or svnadmin is not available") + + +def _skip_if_hg_missing() -> None: + """Skip the calling fixture when the ``hg`` binary is unavailable.""" + if not shutil.which("hg"): + pytest.skip(reason="hg is not available") + + DEFAULT_VCS_NAME = "Test user" DEFAULT_VCS_EMAIL = "test@example.com" @@ -103,7 +121,7 @@ def __next__(self) -> str: def pytest_ignore_collect(collection_path: pathlib.Path, config: pytest.Config) -> bool: """Skip tests if VCS binaries are missing.""" - if not shutil.which("svn") and any( + if (not shutil.which("svn") or not shutil.which("svnadmin")) and any( needle in str(collection_path) for needle in ["svn", "subversion"] ): return True @@ -145,7 +163,6 @@ def set_home( @pytest.fixture(scope="session") -@skip_if_git_missing def vcs_gitconfig( user_path: pathlib.Path, vcs_email: str, @@ -173,7 +190,6 @@ def vcs_gitconfig( @pytest.fixture -@skip_if_git_missing def set_vcs_gitconfig( monkeypatch: pytest.MonkeyPatch, vcs_gitconfig: pathlib.Path, @@ -185,7 +201,6 @@ def set_vcs_gitconfig( @pytest.fixture(scope="session") -@skip_if_hg_missing def vcs_hgconfig( user_path: pathlib.Path, vcs_user: str, @@ -209,7 +224,6 @@ def vcs_hgconfig( @pytest.fixture -@skip_if_hg_missing def set_vcs_hgconfig( monkeypatch: pytest.MonkeyPatch, vcs_hgconfig: pathlib.Path, @@ -338,11 +352,11 @@ def empty_git_bare_repo_path(libvcs_test_cache_path: pathlib.Path) -> pathlib.Pa @pytest.fixture(scope="session") -@skip_if_git_missing def empty_git_bare_repo( empty_git_bare_repo_path: pathlib.Path, ) -> pathlib.Path: """Return factory to create git remote repo to for clone / push purposes.""" + _skip_if_git_missing() if ( empty_git_bare_repo_path.exists() and (empty_git_bare_repo_path / ".git").exists() @@ -357,11 +371,11 @@ def empty_git_bare_repo( @pytest.fixture(scope="session") -@skip_if_git_missing def empty_git_repo( empty_git_repo_path: pathlib.Path, ) -> pathlib.Path: """Return factory to create git remote repo to for clone / push purposes.""" + _skip_if_git_missing() if empty_git_repo_path.exists() and (empty_git_repo_path / ".git").exists(): return empty_git_repo_path @@ -373,12 +387,12 @@ def empty_git_repo( @pytest.fixture(scope="session") -@skip_if_git_missing def create_git_remote_bare_repo( remote_repos_path: pathlib.Path, empty_git_bare_repo: pathlib.Path, ) -> CreateRepoFn: """Return factory to create git remote repo to for clone / push purposes.""" + _skip_if_git_missing() def fn( remote_repos_path: pathlib.Path = remote_repos_path, @@ -402,12 +416,12 @@ def fn( @pytest.fixture(scope="session") -@skip_if_git_missing def create_git_remote_repo( remote_repos_path: pathlib.Path, empty_git_repo: pathlib.Path, ) -> CreateRepoFn: """Return factory to create git remote repo to for clone / push purposes.""" + _skip_if_git_missing() def fn( remote_repos_path: pathlib.Path = remote_repos_path, @@ -455,13 +469,13 @@ def git_remote_repo_single_commit_post_init( @pytest.fixture(scope="session") -@skip_if_git_missing def git_remote_repo( create_git_remote_repo: CreateRepoFn, vcs_gitconfig: pathlib.Path, git_commit_envvars: GitCommitEnvVars, ) -> pathlib.Path: """Copy the session-scoped Git repository to a temporary directory.""" + _skip_if_git_missing() # TODO: Cache the effect of of this in a session-based repo repo_path = create_git_remote_repo() git_remote_repo_single_commit_post_init( @@ -519,15 +533,11 @@ def empty_svn_repo_path(libvcs_test_cache_path: pathlib.Path) -> pathlib.Path: @pytest.fixture(scope="session") -@skip_if_svn_missing def empty_svn_repo( empty_svn_repo_path: pathlib.Path, ) -> pathlib.Path: """Return factory to create svn remote repo to for clone / push purposes.""" - if not shutil.which("svn") or not shutil.which("svnadmin"): - pytest.skip( - reason="svn is not available", - ) + _skip_if_svn_missing() if empty_svn_repo_path.exists() and (empty_svn_repo_path / "conf").exists(): return empty_svn_repo_path @@ -540,12 +550,12 @@ def empty_svn_repo( @pytest.fixture(scope="session") -@skip_if_svn_missing def create_svn_remote_repo( remote_repos_path: pathlib.Path, empty_svn_repo: pathlib.Path, ) -> CreateRepoFn: """Pre-made svn repo, bare, used as a file:// remote to checkout and commit to.""" + _skip_if_svn_missing() def fn( remote_repos_path: pathlib.Path = remote_repos_path, @@ -572,20 +582,20 @@ def fn( @pytest.fixture(scope="session") -@skip_if_svn_missing def svn_remote_repo( create_svn_remote_repo: CreateRepoFn, ) -> pathlib.Path: """Pre-made. Local file:// based SVN server.""" + _skip_if_svn_missing() return create_svn_remote_repo() @pytest.fixture(scope="session") -@skip_if_svn_missing def svn_remote_repo_with_files( create_svn_remote_repo: CreateRepoFn, ) -> pathlib.Path: """Pre-made. Local file:// based SVN server.""" + _skip_if_svn_missing() repo_path = create_svn_remote_repo() svn_remote_repo_single_commit_post_init(remote_repo_path=repo_path) return repo_path @@ -629,11 +639,11 @@ def empty_hg_repo_path(libvcs_test_cache_path: pathlib.Path) -> pathlib.Path: @pytest.fixture(scope="session") -@skip_if_hg_missing def empty_hg_repo( empty_hg_repo_path: pathlib.Path, ) -> pathlib.Path: """Return factory to create hg remote repo to for clone / push purposes.""" + _skip_if_hg_missing() if empty_hg_repo_path.exists() and (empty_hg_repo_path / ".hg").exists(): return empty_hg_repo_path @@ -645,13 +655,13 @@ def empty_hg_repo( @pytest.fixture(scope="session") -@skip_if_hg_missing def create_hg_remote_repo( remote_repos_path: pathlib.Path, empty_hg_repo: pathlib.Path, vcs_hgconfig: pathlib.Path, ) -> CreateRepoFn: """Pre-made hg repo, bare, used as a file:// remote to checkout and commit to.""" + _skip_if_hg_missing() def fn( remote_repos_path: pathlib.Path = remote_repos_path, @@ -681,13 +691,13 @@ def fn( @pytest.fixture(scope="session") -@skip_if_hg_missing def hg_remote_repo( remote_repos_path: pathlib.Path, create_hg_remote_repo: CreateRepoFn, vcs_hgconfig: pathlib.Path, ) -> pathlib.Path: """Pre-made, file-based repo for push and pull.""" + _skip_if_hg_missing() repo_path = create_hg_remote_repo() hg_remote_repo_single_commit_post_init( remote_repo_path=repo_path, @@ -790,12 +800,6 @@ def add_doctest_fixtures( doctest_namespace: dict[str, t.Any], tmp_path: pathlib.Path, set_home: pathlib.Path, - git_commit_envvars: GitCommitEnvVars, - vcs_hgconfig: pathlib.Path, - create_git_remote_repo: CreateRepoFn, - create_svn_remote_repo: CreateRepoFn, - create_hg_remote_repo: CreateRepoFn, - git_repo: pathlib.Path, ) -> None: """Harness pytest fixtures to pytest's doctest namespace.""" from _pytest.doctest import DoctestItem @@ -803,7 +807,11 @@ def add_doctest_fixtures( if not isinstance(request._pyfuncitem, DoctestItem): # Only run on doctest items return doctest_namespace["tmp_path"] = tmp_path + # Request the per-VCS fixtures lazily so a missing binary only drops that + # VCS's doctest helpers -- it does not skip doctests for the others. if shutil.which("git"): + git_commit_envvars = request.getfixturevalue("git_commit_envvars") + create_git_remote_repo = request.getfixturevalue("create_git_remote_repo") doctest_namespace["create_git_remote_repo"] = functools.partial( create_git_remote_repo, remote_repo_post_init=functools.partial( @@ -813,14 +821,17 @@ def add_doctest_fixtures( init_cmd_args=None, ) doctest_namespace["create_git_remote_repo_bare"] = create_git_remote_repo - doctest_namespace["example_git_repo"] = git_repo - if shutil.which("svn"): + doctest_namespace["example_git_repo"] = request.getfixturevalue("git_repo") + if shutil.which("svn") and shutil.which("svnadmin"): + create_svn_remote_repo = request.getfixturevalue("create_svn_remote_repo") doctest_namespace["create_svn_remote_repo_bare"] = create_svn_remote_repo doctest_namespace["create_svn_remote_repo"] = functools.partial( create_svn_remote_repo, remote_repo_post_init=svn_remote_repo_single_commit_post_init, ) if shutil.which("hg"): + vcs_hgconfig = request.getfixturevalue("vcs_hgconfig") + create_hg_remote_repo = request.getfixturevalue("create_hg_remote_repo") doctest_namespace["create_hg_remote_repo_bare"] = create_hg_remote_repo doctest_namespace["create_hg_remote_repo"] = functools.partial( create_hg_remote_repo,