Skip to content

Commit 77d912f

Browse files
authored
pytest_plugin(fix): pytest 9.1 compatibility (#537)
pytest 9.1 promoted marks applied to fixture functions to a hard error. Because libvcs ships its pytest plugin as an installed entry point, the plugin failed to import and aborted the entire test session for any project that had libvcs installed and upgraded to pytest 9.1+. The binary-availability checks that gate the Git, Mercurial, and Subversion fixtures now run inside each fixture, so the plugin loads cleanly and a missing VCS binary skips the affected tests instead of erroring. - **pytest 9.1+ support**: any project with libvcs installed can run pytest 9.1+ again; the session no longer aborts at plugin load. - **Missing binaries skip, not error**: Git, Mercurial, and Subversion fixtures skip when their binary is unavailable -- Subversion needs both `svn` and `svnadmin`. - **No pytest cap**: the fix keeps libvcs working across pytest versions without pinning a maximum pytest.
2 parents 8ac1cf4 + 190dcbb commit 77d912f

3 files changed

Lines changed: 58 additions & 39 deletions

File tree

CHANGES

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,12 @@ $ uv add libvcs --prerelease allow
2020
_Notes on the upcoming release will go here._
2121
<!-- END PLACEHOLDER - ADD NEW CHANGELOG ENTRIES BELOW THIS LINE -->
2222

23+
### Fixes
24+
25+
#### pytest 9.1 compatibility for the pytest plugin (#537)
26+
27+
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.
28+
2329
## libvcs 0.42.0 (2026-05-31)
2430

2531
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.

src/libvcs/cmd/git.py

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4094,17 +4094,19 @@ def set_head(
40944094
40954095
Examples
40964096
--------
4097-
>>> GitRemoteCmd(
4097+
>>> remote = GitRemoteCmd(
40984098
... path=example_git_repo.path,
40994099
... remote_name='origin'
4100-
... ).set_head(auto=True)
4101-
'origin/HEAD set to master'
4100+
... )
41024101
4103-
>>> GitRemoteCmd(
4104-
... path=example_git_repo.path,
4105-
... remote_name='origin'
4106-
... ).set_head('master')
4107-
''
4102+
The exact message wording varies across git versions (git 2.54+ reports
4103+
"is unchanged" when HEAD already points at the branch), so assert on the
4104+
stable parts rather than the literal string:
4105+
4106+
>>> 'master' in remote.set_head(auto=True)
4107+
True
4108+
>>> isinstance(remote.set_head('master'), str)
4109+
True
41084110
"""
41094111
local_flags: list[str] = [self.remote_name]
41104112

src/libvcs/pytest_plugin.py

Lines changed: 42 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -34,15 +34,33 @@ def __init__(self, attempts: int, *args: object) -> None:
3434
reason="git is not available",
3535
)
3636
skip_if_svn_missing = pytest.mark.skipif(
37-
not shutil.which("svn"),
38-
reason="svn is not available",
37+
not shutil.which("svn") or not shutil.which("svnadmin"),
38+
reason="svn or svnadmin is not available",
3939
)
4040
skip_if_hg_missing = pytest.mark.skipif(
4141
not shutil.which("hg"),
4242
reason="hg is not available",
4343
)
4444

4545

46+
def _skip_if_git_missing() -> None:
47+
"""Skip the calling fixture when the ``git`` binary is unavailable."""
48+
if not shutil.which("git"):
49+
pytest.skip(reason="git is not available")
50+
51+
52+
def _skip_if_svn_missing() -> None:
53+
"""Skip the calling fixture when ``svn`` or ``svnadmin`` is unavailable."""
54+
if not shutil.which("svn") or not shutil.which("svnadmin"):
55+
pytest.skip(reason="svn or svnadmin is not available")
56+
57+
58+
def _skip_if_hg_missing() -> None:
59+
"""Skip the calling fixture when the ``hg`` binary is unavailable."""
60+
if not shutil.which("hg"):
61+
pytest.skip(reason="hg is not available")
62+
63+
4664
DEFAULT_VCS_NAME = "Test user"
4765
DEFAULT_VCS_EMAIL = "test@example.com"
4866

@@ -103,7 +121,7 @@ def __next__(self) -> str:
103121

104122
def pytest_ignore_collect(collection_path: pathlib.Path, config: pytest.Config) -> bool:
105123
"""Skip tests if VCS binaries are missing."""
106-
if not shutil.which("svn") and any(
124+
if (not shutil.which("svn") or not shutil.which("svnadmin")) and any(
107125
needle in str(collection_path) for needle in ["svn", "subversion"]
108126
):
109127
return True
@@ -145,7 +163,6 @@ def set_home(
145163

146164

147165
@pytest.fixture(scope="session")
148-
@skip_if_git_missing
149166
def vcs_gitconfig(
150167
user_path: pathlib.Path,
151168
vcs_email: str,
@@ -173,7 +190,6 @@ def vcs_gitconfig(
173190

174191

175192
@pytest.fixture
176-
@skip_if_git_missing
177193
def set_vcs_gitconfig(
178194
monkeypatch: pytest.MonkeyPatch,
179195
vcs_gitconfig: pathlib.Path,
@@ -185,7 +201,6 @@ def set_vcs_gitconfig(
185201

186202

187203
@pytest.fixture(scope="session")
188-
@skip_if_hg_missing
189204
def vcs_hgconfig(
190205
user_path: pathlib.Path,
191206
vcs_user: str,
@@ -209,7 +224,6 @@ def vcs_hgconfig(
209224

210225

211226
@pytest.fixture
212-
@skip_if_hg_missing
213227
def set_vcs_hgconfig(
214228
monkeypatch: pytest.MonkeyPatch,
215229
vcs_hgconfig: pathlib.Path,
@@ -338,11 +352,11 @@ def empty_git_bare_repo_path(libvcs_test_cache_path: pathlib.Path) -> pathlib.Pa
338352

339353

340354
@pytest.fixture(scope="session")
341-
@skip_if_git_missing
342355
def empty_git_bare_repo(
343356
empty_git_bare_repo_path: pathlib.Path,
344357
) -> pathlib.Path:
345358
"""Return factory to create git remote repo to for clone / push purposes."""
359+
_skip_if_git_missing()
346360
if (
347361
empty_git_bare_repo_path.exists()
348362
and (empty_git_bare_repo_path / ".git").exists()
@@ -357,11 +371,11 @@ def empty_git_bare_repo(
357371

358372

359373
@pytest.fixture(scope="session")
360-
@skip_if_git_missing
361374
def empty_git_repo(
362375
empty_git_repo_path: pathlib.Path,
363376
) -> pathlib.Path:
364377
"""Return factory to create git remote repo to for clone / push purposes."""
378+
_skip_if_git_missing()
365379
if empty_git_repo_path.exists() and (empty_git_repo_path / ".git").exists():
366380
return empty_git_repo_path
367381

@@ -373,12 +387,12 @@ def empty_git_repo(
373387

374388

375389
@pytest.fixture(scope="session")
376-
@skip_if_git_missing
377390
def create_git_remote_bare_repo(
378391
remote_repos_path: pathlib.Path,
379392
empty_git_bare_repo: pathlib.Path,
380393
) -> CreateRepoFn:
381394
"""Return factory to create git remote repo to for clone / push purposes."""
395+
_skip_if_git_missing()
382396

383397
def fn(
384398
remote_repos_path: pathlib.Path = remote_repos_path,
@@ -402,12 +416,12 @@ def fn(
402416

403417

404418
@pytest.fixture(scope="session")
405-
@skip_if_git_missing
406419
def create_git_remote_repo(
407420
remote_repos_path: pathlib.Path,
408421
empty_git_repo: pathlib.Path,
409422
) -> CreateRepoFn:
410423
"""Return factory to create git remote repo to for clone / push purposes."""
424+
_skip_if_git_missing()
411425

412426
def fn(
413427
remote_repos_path: pathlib.Path = remote_repos_path,
@@ -455,13 +469,13 @@ def git_remote_repo_single_commit_post_init(
455469

456470

457471
@pytest.fixture(scope="session")
458-
@skip_if_git_missing
459472
def git_remote_repo(
460473
create_git_remote_repo: CreateRepoFn,
461474
vcs_gitconfig: pathlib.Path,
462475
git_commit_envvars: GitCommitEnvVars,
463476
) -> pathlib.Path:
464477
"""Copy the session-scoped Git repository to a temporary directory."""
478+
_skip_if_git_missing()
465479
# TODO: Cache the effect of of this in a session-based repo
466480
repo_path = create_git_remote_repo()
467481
git_remote_repo_single_commit_post_init(
@@ -519,15 +533,11 @@ def empty_svn_repo_path(libvcs_test_cache_path: pathlib.Path) -> pathlib.Path:
519533

520534

521535
@pytest.fixture(scope="session")
522-
@skip_if_svn_missing
523536
def empty_svn_repo(
524537
empty_svn_repo_path: pathlib.Path,
525538
) -> pathlib.Path:
526539
"""Return factory to create svn remote repo to for clone / push purposes."""
527-
if not shutil.which("svn") or not shutil.which("svnadmin"):
528-
pytest.skip(
529-
reason="svn is not available",
530-
)
540+
_skip_if_svn_missing()
531541

532542
if empty_svn_repo_path.exists() and (empty_svn_repo_path / "conf").exists():
533543
return empty_svn_repo_path
@@ -540,12 +550,12 @@ def empty_svn_repo(
540550

541551

542552
@pytest.fixture(scope="session")
543-
@skip_if_svn_missing
544553
def create_svn_remote_repo(
545554
remote_repos_path: pathlib.Path,
546555
empty_svn_repo: pathlib.Path,
547556
) -> CreateRepoFn:
548557
"""Pre-made svn repo, bare, used as a file:// remote to checkout and commit to."""
558+
_skip_if_svn_missing()
549559

550560
def fn(
551561
remote_repos_path: pathlib.Path = remote_repos_path,
@@ -572,20 +582,20 @@ def fn(
572582

573583

574584
@pytest.fixture(scope="session")
575-
@skip_if_svn_missing
576585
def svn_remote_repo(
577586
create_svn_remote_repo: CreateRepoFn,
578587
) -> pathlib.Path:
579588
"""Pre-made. Local file:// based SVN server."""
589+
_skip_if_svn_missing()
580590
return create_svn_remote_repo()
581591

582592

583593
@pytest.fixture(scope="session")
584-
@skip_if_svn_missing
585594
def svn_remote_repo_with_files(
586595
create_svn_remote_repo: CreateRepoFn,
587596
) -> pathlib.Path:
588597
"""Pre-made. Local file:// based SVN server."""
598+
_skip_if_svn_missing()
589599
repo_path = create_svn_remote_repo()
590600
svn_remote_repo_single_commit_post_init(remote_repo_path=repo_path)
591601
return repo_path
@@ -629,11 +639,11 @@ def empty_hg_repo_path(libvcs_test_cache_path: pathlib.Path) -> pathlib.Path:
629639

630640

631641
@pytest.fixture(scope="session")
632-
@skip_if_hg_missing
633642
def empty_hg_repo(
634643
empty_hg_repo_path: pathlib.Path,
635644
) -> pathlib.Path:
636645
"""Return factory to create hg remote repo to for clone / push purposes."""
646+
_skip_if_hg_missing()
637647
if empty_hg_repo_path.exists() and (empty_hg_repo_path / ".hg").exists():
638648
return empty_hg_repo_path
639649

@@ -645,13 +655,13 @@ def empty_hg_repo(
645655

646656

647657
@pytest.fixture(scope="session")
648-
@skip_if_hg_missing
649658
def create_hg_remote_repo(
650659
remote_repos_path: pathlib.Path,
651660
empty_hg_repo: pathlib.Path,
652661
vcs_hgconfig: pathlib.Path,
653662
) -> CreateRepoFn:
654663
"""Pre-made hg repo, bare, used as a file:// remote to checkout and commit to."""
664+
_skip_if_hg_missing()
655665

656666
def fn(
657667
remote_repos_path: pathlib.Path = remote_repos_path,
@@ -681,13 +691,13 @@ def fn(
681691

682692

683693
@pytest.fixture(scope="session")
684-
@skip_if_hg_missing
685694
def hg_remote_repo(
686695
remote_repos_path: pathlib.Path,
687696
create_hg_remote_repo: CreateRepoFn,
688697
vcs_hgconfig: pathlib.Path,
689698
) -> pathlib.Path:
690699
"""Pre-made, file-based repo for push and pull."""
700+
_skip_if_hg_missing()
691701
repo_path = create_hg_remote_repo()
692702
hg_remote_repo_single_commit_post_init(
693703
remote_repo_path=repo_path,
@@ -790,20 +800,18 @@ def add_doctest_fixtures(
790800
doctest_namespace: dict[str, t.Any],
791801
tmp_path: pathlib.Path,
792802
set_home: pathlib.Path,
793-
git_commit_envvars: GitCommitEnvVars,
794-
vcs_hgconfig: pathlib.Path,
795-
create_git_remote_repo: CreateRepoFn,
796-
create_svn_remote_repo: CreateRepoFn,
797-
create_hg_remote_repo: CreateRepoFn,
798-
git_repo: pathlib.Path,
799803
) -> None:
800804
"""Harness pytest fixtures to pytest's doctest namespace."""
801805
from _pytest.doctest import DoctestItem
802806

803807
if not isinstance(request._pyfuncitem, DoctestItem): # Only run on doctest items
804808
return
805809
doctest_namespace["tmp_path"] = tmp_path
810+
# Request the per-VCS fixtures lazily so a missing binary only drops that
811+
# VCS's doctest helpers -- it does not skip doctests for the others.
806812
if shutil.which("git"):
813+
git_commit_envvars = request.getfixturevalue("git_commit_envvars")
814+
create_git_remote_repo = request.getfixturevalue("create_git_remote_repo")
807815
doctest_namespace["create_git_remote_repo"] = functools.partial(
808816
create_git_remote_repo,
809817
remote_repo_post_init=functools.partial(
@@ -813,14 +821,17 @@ def add_doctest_fixtures(
813821
init_cmd_args=None,
814822
)
815823
doctest_namespace["create_git_remote_repo_bare"] = create_git_remote_repo
816-
doctest_namespace["example_git_repo"] = git_repo
817-
if shutil.which("svn"):
824+
doctest_namespace["example_git_repo"] = request.getfixturevalue("git_repo")
825+
if shutil.which("svn") and shutil.which("svnadmin"):
826+
create_svn_remote_repo = request.getfixturevalue("create_svn_remote_repo")
818827
doctest_namespace["create_svn_remote_repo_bare"] = create_svn_remote_repo
819828
doctest_namespace["create_svn_remote_repo"] = functools.partial(
820829
create_svn_remote_repo,
821830
remote_repo_post_init=svn_remote_repo_single_commit_post_init,
822831
)
823832
if shutil.which("hg"):
833+
vcs_hgconfig = request.getfixturevalue("vcs_hgconfig")
834+
create_hg_remote_repo = request.getfixturevalue("create_hg_remote_repo")
824835
doctest_namespace["create_hg_remote_repo_bare"] = create_hg_remote_repo
825836
doctest_namespace["create_hg_remote_repo"] = functools.partial(
826837
create_hg_remote_repo,

0 commit comments

Comments
 (0)