diff --git a/CHANGES b/CHANGES index 23b020e3..b37e8d0e 100644 --- a/CHANGES +++ b/CHANGES @@ -20,6 +20,27 @@ $ uv add libvcs --prerelease allow _Notes on the upcoming release will go here._ +### What's new + +#### pytest plugin: Improve typings (#521) + +The pytest plugin now exports concise public TypeAliases in place of +the private `_ENV` type that was leaking into public signatures: + +- `Env` — public alias for the subprocess environment mapping type; + replaces `_ENV` in all `env:` parameters across Protocol classes and + helper functions +- `GitCommitEnvVars` — alias for `dict[str, str]`, the type returned + by the `git_commit_envvars` fixture + +`CreateRepoPytestFixtureFn` is renamed to `CreateRepoFn`. Update any +`TYPE_CHECKING` imports accordingly. + +### Documentation + +- pytest plugin: `CreateRepoFn` and `CreateRepoPostInitFn` now render + as hyperlinks in the fixture summary table (#521) + ## libvcs 0.39.0 (2026-02-07) ### New features diff --git a/README.md b/README.md index aa16a120..abddc30b 100644 --- a/README.md +++ b/README.md @@ -134,11 +134,11 @@ Writing a tool that interacts with VCS? Use our fixtures to keep your tests clea ```python import pathlib -from libvcs.pytest_plugin import CreateRepoPytestFixtureFn +from libvcs.pytest_plugin import CreateRepoFn from libvcs.sync.git import GitSync def test_my_git_tool( - create_git_remote_repo: CreateRepoPytestFixtureFn, + create_git_remote_repo: CreateRepoFn, tmp_path: pathlib.Path ): # Spin up a real, temporary Git server diff --git a/docs/api/pytest-plugin.md b/docs/api/pytest-plugin.md index b5fc0e52..d939dadf 100644 --- a/docs/api/pytest-plugin.md +++ b/docs/api/pytest-plugin.md @@ -41,3 +41,17 @@ def setup( pass ``` ::: + +## Types + +```{eval-rst} +.. autodata:: libvcs.pytest_plugin.GitCommitEnvVars + +.. autoclass:: libvcs.pytest_plugin.CreateRepoFn + :special-members: __call__ + :exclude-members: __init__, _abc_impl, _is_protocol + +.. autoclass:: libvcs.pytest_plugin.CreateRepoPostInitFn + :special-members: __call__ + :exclude-members: __init__, _abc_impl, _is_protocol +``` diff --git a/docs/conf.py b/docs/conf.py index 01aec4b9..370165c3 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -4,6 +4,14 @@ import pathlib import sys +import typing as t + +if t.TYPE_CHECKING: + from docutils import nodes # type: ignore[import-untyped] + from sphinx import addnodes + from sphinx.application import Sphinx + from sphinx.domains.python import PythonDomain + from sphinx.environment import BuildEnvironment from gp_sphinx.config import make_linkcode_resolve, merge_sphinx_config @@ -54,3 +62,33 @@ rediraffe_redirects="redirects.txt", ) globals().update(conf) + + +def _on_missing_class_reference( + app: Sphinx, + env: BuildEnvironment, + node: addnodes.pending_xref, + contnode: nodes.TextElement, +) -> nodes.reference | None: + if node.get("refdomain") != "py" or node.get("reftype") != "class": + return None + from sphinx.util.nodes import make_refnode + + py_domain: PythonDomain = env.get_domain("py") # type: ignore[assignment] + target = node.get("reftarget", "") + matches = py_domain.find_obj(env, "", "", target, None, 1) # type: ignore[attr-defined,unused-ignore] + if not matches: + return None + _name, obj_entry = matches[0] + return make_refnode( + app.builder, + node.get("refdoc", ""), + obj_entry.docname, + obj_entry.node_id, + contnode, + ) + + +def setup(app: Sphinx) -> None: + """Connect missing-reference handler to resolve py:data as :class: links.""" + app.connect("missing-reference", _on_missing_class_reference) diff --git a/src/libvcs/pytest_plugin.py b/src/libvcs/pytest_plugin.py index 07a0717d..131688ff 100644 --- a/src/libvcs/pytest_plugin.py +++ b/src/libvcs/pytest_plugin.py @@ -66,7 +66,7 @@ def vcs_user(vcs_name: str, vcs_email: str) -> str: @pytest.fixture(scope="session") -def git_commit_envvars(vcs_name: str, vcs_email: str) -> _ENV: +def git_commit_envvars(vcs_name: str, vcs_email: str) -> GitCommitEnvVars: """Return environment variables for `git commit`. For some reason, `GIT_CONFIG` via {func}`set_gitconfig` doesn't work for `git @@ -266,6 +266,9 @@ def unique_repo_name(remote_repos_path: pathlib.Path, max_retries: int = 15) -> InitCmdArgs: t.TypeAlias = list[str] | None +GitCommitEnvVars: t.TypeAlias = dict[str, str] +"""Environment variable mapping passed to ``git commit`` subprocess calls.""" +Env: t.TypeAlias = _ENV class CreateRepoPostInitFn(t.Protocol): @@ -274,13 +277,13 @@ class CreateRepoPostInitFn(t.Protocol): def __call__( self, remote_repo_path: pathlib.Path, - env: _ENV | None = None, + env: Env | None = None, ) -> None: """Ran after creating a repo from pytest fixture.""" ... -class CreateRepoPytestFixtureFn(t.Protocol): +class CreateRepoFn(t.Protocol): """Typing for VCS pytest fixture callback.""" def __call__( @@ -301,7 +304,7 @@ def _create_git_remote_repo( remote_repo_path: pathlib.Path, remote_repo_post_init: CreateRepoPostInitFn | None = None, init_cmd_args: InitCmdArgs = DEFAULT_GIT_REMOTE_REPO_CMD_ARGS, - env: _ENV | None = None, + env: Env | None = None, ) -> pathlib.Path: if init_cmd_args is None: init_cmd_args = [] @@ -374,7 +377,7 @@ def empty_git_repo( def create_git_remote_bare_repo( remote_repos_path: pathlib.Path, empty_git_bare_repo: pathlib.Path, -) -> CreateRepoPytestFixtureFn: +) -> CreateRepoFn: """Return factory to create git remote repo to for clone / push purposes.""" def fn( @@ -403,7 +406,7 @@ def fn( def create_git_remote_repo( remote_repos_path: pathlib.Path, empty_git_repo: pathlib.Path, -) -> CreateRepoPytestFixtureFn: +) -> CreateRepoFn: """Return factory to create git remote repo to for clone / push purposes.""" def fn( @@ -434,7 +437,7 @@ def fn( def git_remote_repo_single_commit_post_init( remote_repo_path: pathlib.Path, - env: _ENV | None = None, + env: Env | None = None, ) -> None: """Post-initialization: Create a test git repo with a single commit.""" testfile_filename = "testfile.test" @@ -454,9 +457,9 @@ def git_remote_repo_single_commit_post_init( @pytest.fixture(scope="session") @skip_if_git_missing def git_remote_repo( - create_git_remote_repo: CreateRepoPytestFixtureFn, + create_git_remote_repo: CreateRepoFn, gitconfig: pathlib.Path, - git_commit_envvars: _ENV, + git_commit_envvars: GitCommitEnvVars, ) -> pathlib.Path: """Copy the session-scoped Git repository to a temporary directory.""" # TODO: Cache the effect of of this in a session-based repo @@ -490,7 +493,7 @@ def _create_svn_remote_repo( def svn_remote_repo_single_commit_post_init( remote_repo_path: pathlib.Path, - env: _ENV | None = None, + env: Env | None = None, ) -> None: """Post-initialization: Create a test SVN repo with a single commit.""" assert remote_repo_path.exists() @@ -541,7 +544,7 @@ def empty_svn_repo( def create_svn_remote_repo( remote_repos_path: pathlib.Path, empty_svn_repo: pathlib.Path, -) -> CreateRepoPytestFixtureFn: +) -> CreateRepoFn: """Pre-made svn repo, bare, used as a file:// remote to checkout and commit to.""" def fn( @@ -571,7 +574,7 @@ def fn( @pytest.fixture(scope="session") @skip_if_svn_missing def svn_remote_repo( - create_svn_remote_repo: CreateRepoPytestFixtureFn, + create_svn_remote_repo: CreateRepoFn, ) -> pathlib.Path: """Pre-made. Local file:// based SVN server.""" return create_svn_remote_repo() @@ -580,7 +583,7 @@ def svn_remote_repo( @pytest.fixture(scope="session") @skip_if_svn_missing def svn_remote_repo_with_files( - create_svn_remote_repo: CreateRepoPytestFixtureFn, + create_svn_remote_repo: CreateRepoFn, ) -> pathlib.Path: """Pre-made. Local file:// based SVN server.""" repo_path = create_svn_remote_repo() @@ -610,7 +613,7 @@ def _create_hg_remote_repo( def hg_remote_repo_single_commit_post_init( remote_repo_path: pathlib.Path, - env: _ENV | None = None, + env: Env | None = None, ) -> None: """Post-initialization: Create a test mercurial repo with a single commit.""" testfile_filename = "testfile.test" @@ -647,7 +650,7 @@ def create_hg_remote_repo( remote_repos_path: pathlib.Path, empty_hg_repo: pathlib.Path, hgconfig: pathlib.Path, -) -> CreateRepoPytestFixtureFn: +) -> CreateRepoFn: """Pre-made hg repo, bare, used as a file:// remote to checkout and commit to.""" def fn( @@ -681,7 +684,7 @@ def fn( @skip_if_hg_missing def hg_remote_repo( remote_repos_path: pathlib.Path, - create_hg_remote_repo: CreateRepoPytestFixtureFn, + create_hg_remote_repo: CreateRepoFn, hgconfig: pathlib.Path, ) -> pathlib.Path: """Pre-made, file-based repo for push and pull.""" @@ -787,11 +790,11 @@ def add_doctest_fixtures( doctest_namespace: dict[str, t.Any], tmp_path: pathlib.Path, set_home: pathlib.Path, - git_commit_envvars: _ENV, + git_commit_envvars: GitCommitEnvVars, hgconfig: pathlib.Path, - create_git_remote_repo: CreateRepoPytestFixtureFn, - create_svn_remote_repo: CreateRepoPytestFixtureFn, - create_hg_remote_repo: CreateRepoPytestFixtureFn, + 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.""" diff --git a/tests/cmd/test_git.py b/tests/cmd/test_git.py index dcbfdfe5..420b6f47 100644 --- a/tests/cmd/test_git.py +++ b/tests/cmd/test_git.py @@ -12,7 +12,7 @@ from libvcs.cmd import git if t.TYPE_CHECKING: - from libvcs.pytest_plugin import CreateRepoPytestFixtureFn + from libvcs.pytest_plugin import CreateRepoFn, GitCommitEnvVars from libvcs.sync.git import GitSync @@ -956,7 +956,7 @@ class RemoteAddParamFixture(t.NamedTuple): ) def test_remote_manager_add_params( git_repo: GitSync, - create_git_remote_repo: CreateRepoPytestFixtureFn, + create_git_remote_repo: CreateRepoFn, test_id: str, fetch: bool | None, track: str | None, @@ -2287,7 +2287,7 @@ def test_reflog_entry_delete(git_repo: GitSync) -> None: @pytest.fixture def submodule_repo( tmp_path: pathlib.Path, - git_commit_envvars: dict[str, str], + git_commit_envvars: GitCommitEnvVars, set_gitconfig: pathlib.Path, ) -> git.Git: """Create a git repository to use as a submodule source.""" diff --git a/tests/sync/test_git.py b/tests/sync/test_git.py index 61b8584e..8759f9e0 100644 --- a/tests/sync/test_git.py +++ b/tests/sync/test_git.py @@ -26,7 +26,7 @@ if t.TYPE_CHECKING: from pytest_mock import MockerFixture - from libvcs.pytest_plugin import CreateRepoPytestFixtureFn + from libvcs.pytest_plugin import CreateRepoFn if not shutil.which("git"): pytestmark = pytest.mark.skip(reason="git is not available") @@ -182,7 +182,7 @@ def test_repo_update_handle_cases( ) def test_repo_update_stash_cases( tmp_path: pathlib.Path, - create_git_remote_bare_repo: CreateRepoPytestFixtureFn, + create_git_remote_bare_repo: CreateRepoFn, mocker: MockerFixture, has_untracked_files: bool, needs_stash: bool, @@ -551,7 +551,7 @@ def test_remotes_update_repo( lazy_constructor_options: ProjectTestFactoryLazyKwargs, lazy_remote_dict: ProjectTestFactoryRemoteLazyExpected, lazy_remote_expected: ProjectTestFactoryRemoteLazyExpected, - create_git_remote_repo: CreateRepoPytestFixtureFn, + create_git_remote_repo: CreateRepoFn, ) -> None: """Tests GitSync with updated remotes.""" repo_name = "myrepo" @@ -911,7 +911,7 @@ def test_GitRemote__from_stdout_c(fixture: str, expected_result: GitStatus) -> N def test_repo_git_remote_checkout( - create_git_remote_repo: CreateRepoPytestFixtureFn, + create_git_remote_repo: CreateRepoFn, tmp_path: pathlib.Path, projects_path: pathlib.Path, ) -> None: @@ -930,7 +930,7 @@ def test_repo_git_remote_checkout( def test_update_repo_success_returns_sync_result( - create_git_remote_bare_repo: CreateRepoPytestFixtureFn, + create_git_remote_bare_repo: CreateRepoFn, tmp_path: pathlib.Path, ) -> None: """Test that a successful update_repo() returns SyncResult with ok=True.""" @@ -957,7 +957,7 @@ def test_update_repo_success_returns_sync_result( def test_update_repo_fetch_failure_returns_sync_result( - create_git_remote_bare_repo: CreateRepoPytestFixtureFn, + create_git_remote_bare_repo: CreateRepoFn, tmp_path: pathlib.Path, ) -> None: """Test that a fetch failure in update_repo() returns SyncResult with error.""" @@ -998,7 +998,7 @@ def test_update_repo_fetch_failure_returns_sync_result( def test_update_repo_checkout_failure_returns_sync_result( - create_git_remote_bare_repo: CreateRepoPytestFixtureFn, + create_git_remote_bare_repo: CreateRepoFn, tmp_path: pathlib.Path, ) -> None: """Test that a checkout failure in update_repo() returns SyncResult with error.""" @@ -1031,7 +1031,7 @@ def test_update_repo_checkout_failure_returns_sync_result( def test_update_repo_rev_list_head_failure_returns_sync_result( - create_git_remote_bare_repo: CreateRepoPytestFixtureFn, + create_git_remote_bare_repo: CreateRepoFn, tmp_path: pathlib.Path, ) -> None: """update_repo() records rev-list HEAD failure in SyncResult.""" @@ -1065,7 +1065,7 @@ def test_update_repo_rev_list_head_failure_returns_sync_result( def test_update_repo_submodule_failure_recorded( - create_git_remote_bare_repo: CreateRepoPytestFixtureFn, + create_git_remote_bare_repo: CreateRepoFn, tmp_path: pathlib.Path, mocker: MockerFixture, ) -> None: @@ -1118,7 +1118,7 @@ def test_update_repo_submodule_failure_recorded( def test_update_repo_symbolic_ref_failure_recorded( - create_git_remote_bare_repo: CreateRepoPytestFixtureFn, + create_git_remote_bare_repo: CreateRepoFn, tmp_path: pathlib.Path, ) -> None: """Test that symbolic_ref failure on detached HEAD is recorded in SyncResult. @@ -1158,7 +1158,7 @@ def test_update_repo_symbolic_ref_failure_recorded( def test_update_repo_remote_ref_not_found_recorded( - create_git_remote_bare_repo: CreateRepoPytestFixtureFn, + create_git_remote_bare_repo: CreateRepoFn, tmp_path: pathlib.Path, ) -> None: """Test that GitRemoteRefNotFound is caught and recorded in SyncResult. @@ -1200,7 +1200,7 @@ def test_update_repo_remote_ref_not_found_recorded( def test_update_repo_obtain_failure_recorded( - create_git_remote_bare_repo: CreateRepoPytestFixtureFn, + create_git_remote_bare_repo: CreateRepoFn, tmp_path: pathlib.Path, mocker: MockerFixture, ) -> None: @@ -1237,7 +1237,7 @@ def test_update_repo_obtain_failure_recorded( def test_update_repo_set_remotes_failure_recorded( - create_git_remote_bare_repo: CreateRepoPytestFixtureFn, + create_git_remote_bare_repo: CreateRepoFn, tmp_path: pathlib.Path, mocker: MockerFixture, ) -> None: @@ -1281,7 +1281,7 @@ def test_update_repo_set_remotes_failure_recorded( def test_update_repo_remote_name_failure_recorded( - create_git_remote_bare_repo: CreateRepoPytestFixtureFn, + create_git_remote_bare_repo: CreateRepoFn, tmp_path: pathlib.Path, mocker: MockerFixture, ) -> None: @@ -1324,7 +1324,7 @@ def test_update_repo_remote_name_failure_recorded( def test_update_repo_status_failure_recorded( - create_git_remote_bare_repo: CreateRepoPytestFixtureFn, + create_git_remote_bare_repo: CreateRepoFn, tmp_path: pathlib.Path, mocker: MockerFixture, ) -> None: @@ -1385,7 +1385,7 @@ def status_side_effect(**kwargs: t.Any) -> str: def test_update_repo_stash_save_failure_recorded( - create_git_remote_bare_repo: CreateRepoPytestFixtureFn, + create_git_remote_bare_repo: CreateRepoFn, tmp_path: pathlib.Path, mocker: MockerFixture, ) -> None: @@ -1451,7 +1451,7 @@ def status_side_effect(**kwargs: t.Any) -> str: def test_update_repo_rebase_invalid_upstream_recorded( - create_git_remote_bare_repo: CreateRepoPytestFixtureFn, + create_git_remote_bare_repo: CreateRepoFn, tmp_path: pathlib.Path, mocker: MockerFixture, ) -> None: @@ -1519,7 +1519,7 @@ def status_side_effect(**kwargs: t.Any) -> str: def test_update_repo_rebase_conflict_recorded( - create_git_remote_bare_repo: CreateRepoPytestFixtureFn, + create_git_remote_bare_repo: CreateRepoFn, tmp_path: pathlib.Path, mocker: MockerFixture, ) -> None: @@ -1587,7 +1587,7 @@ def status_side_effect(**kwargs: t.Any) -> str: def test_update_repo_stash_pop_failure_recorded( - create_git_remote_bare_repo: CreateRepoPytestFixtureFn, + create_git_remote_bare_repo: CreateRepoFn, tmp_path: pathlib.Path, mocker: MockerFixture, ) -> None: diff --git a/tests/sync/test_hg.py b/tests/sync/test_hg.py index 29a41e81..b19f13c0 100644 --- a/tests/sync/test_hg.py +++ b/tests/sync/test_hg.py @@ -16,7 +16,7 @@ from libvcs.sync.hg import HgSync if t.TYPE_CHECKING: - from libvcs.pytest_plugin import CreateRepoPytestFixtureFn + from libvcs.pytest_plugin import CreateRepoFn if not shutil.which("hg"): pytestmark = pytest.mark.skip(reason="hg is not available") @@ -113,7 +113,7 @@ def test_vulnerability_2022_03_12_command_injection( def test_update_repo_pull_failure_returns_sync_result( projects_path: pathlib.Path, - create_hg_remote_repo: CreateRepoPytestFixtureFn, + create_hg_remote_repo: CreateRepoFn, ) -> None: """Test that a deleted remote in update_repo() returns SyncResult with error.""" repo_name = "my_hg_error_project" diff --git a/tests/sync/test_svn.py b/tests/sync/test_svn.py index fe20cb31..3d0d888e 100644 --- a/tests/sync/test_svn.py +++ b/tests/sync/test_svn.py @@ -14,7 +14,7 @@ if t.TYPE_CHECKING: import pathlib - from libvcs.pytest_plugin import CreateRepoPytestFixtureFn + from libvcs.pytest_plugin import CreateRepoFn if not shutil.which("svn"): pytestmark = pytest.mark.skip(reason="svn is not available") @@ -60,7 +60,7 @@ def test_svn_sync_with_files( def test_repo_svn_remote_checkout( - create_svn_remote_repo: CreateRepoPytestFixtureFn, + create_svn_remote_repo: CreateRepoFn, tmp_path: pathlib.Path, projects_path: pathlib.Path, ) -> None: @@ -79,7 +79,7 @@ def test_repo_svn_remote_checkout( def test_update_repo_checkout_failure_returns_sync_result( - create_svn_remote_repo: CreateRepoPytestFixtureFn, + create_svn_remote_repo: CreateRepoFn, tmp_path: pathlib.Path, projects_path: pathlib.Path, ) -> None: diff --git a/tests/test_pytest_plugin.py b/tests/test_pytest_plugin.py index aa0896a3..aa4e1a5a 100644 --- a/tests/test_pytest_plugin.py +++ b/tests/test_pytest_plugin.py @@ -15,13 +15,13 @@ if t.TYPE_CHECKING: import pathlib - from libvcs.pytest_plugin import CreateRepoPytestFixtureFn + from libvcs.pytest_plugin import CreateRepoFn, GitCommitEnvVars from libvcs.sync.git import GitSync @pytest.mark.skipif(not shutil.which("git"), reason="git is not available") def test_create_git_remote_repo( - create_git_remote_repo: CreateRepoPytestFixtureFn, + create_git_remote_repo: CreateRepoFn, tmp_path: pathlib.Path, projects_path: pathlib.Path, ) -> None: @@ -34,7 +34,7 @@ def test_create_git_remote_repo( @pytest.mark.skipif(not shutil.which("svn"), reason="svn is not available") def test_create_svn_remote_repo( - create_svn_remote_repo: CreateRepoPytestFixtureFn, + create_svn_remote_repo: CreateRepoFn, tmp_path: pathlib.Path, projects_path: pathlib.Path, ) -> None: @@ -112,13 +112,13 @@ def setup( from libvcs.sync.git import GitSync from libvcs.pytest_plugin import ( - CreateRepoPytestFixtureFn, + CreateRepoFn, git_remote_repo_single_commit_post_init ) def test_repo_git_remote_repo_and_sync( - create_git_remote_repo: CreateRepoPytestFixtureFn, + create_git_remote_repo: CreateRepoFn, tmp_path: pathlib.Path, projects_path: pathlib.Path, ) -> None: @@ -136,7 +136,7 @@ def test_repo_git_remote_repo_and_sync( def test_git_bare_repo_sync_and_commit( - create_git_remote_bare_repo: CreateRepoPytestFixtureFn, + create_git_remote_bare_repo: CreateRepoFn, projects_path: pathlib.Path, ) -> None: git_server = create_git_remote_bare_repo() @@ -264,8 +264,8 @@ def test_gitconfig_submodule_file_protocol( @pytest.mark.skipif(not shutil.which("git"), reason="git is not available") def test_git_repo_fixture_submodule_file_protocol( git_repo: GitSync, - create_git_remote_repo: CreateRepoPytestFixtureFn, - git_commit_envvars: dict[str, str], + create_git_remote_repo: CreateRepoFn, + git_commit_envvars: GitCommitEnvVars, user_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch, ) -> None: