From 927d563fa7138835aa26048361fbc2fbebfc2e8b Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 5 Apr 2026 19:57:55 -0500 Subject: [PATCH 1/9] pytest_plugin(refactor[types]): Extract env types to public TypeAliases why: `_ENV` from `_internal/run.py` leaked into public API signatures, causing Sphinx to render its full expanded form (`Mapping[bytes, ...] | Mapping[str, ...]`) in the docs instead of a linkable name. `dict[str, str]` was also used ad-hoc in test annotations without a shared name. what: - Add `Env: t.TypeAlias = _ENV` as public alias for the subprocess env type; replace `env: _ENV | None` in all public signatures - Add `GitCommitEnvVars: t.TypeAlias = dict[str, str]` for the narrower git-commit env type; replace `git_commit_envvars: _ENV` in fixture parameters - Update test TYPE_CHECKING imports to use `GitCommitEnvVars` --- src/libvcs/pytest_plugin.py | 18 ++++++++++-------- tests/cmd/test_git.py | 4 ++-- tests/test_pytest_plugin.py | 4 ++-- 3 files changed, 14 insertions(+), 12 deletions(-) diff --git a/src/libvcs/pytest_plugin.py b/src/libvcs/pytest_plugin.py index 07a0717d..ba714033 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,8 @@ 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] +Env: t.TypeAlias = _ENV class CreateRepoPostInitFn(t.Protocol): @@ -274,7 +276,7 @@ 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.""" ... @@ -301,7 +303,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 = [] @@ -434,7 +436,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" @@ -456,7 +458,7 @@ def git_remote_repo_single_commit_post_init( def git_remote_repo( create_git_remote_repo: CreateRepoPytestFixtureFn, 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 +492,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() @@ -610,7 +612,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" @@ -787,7 +789,7 @@ 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, diff --git a/tests/cmd/test_git.py b/tests/cmd/test_git.py index dcbfdfe5..674bcceb 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 CreateRepoPytestFixtureFn, GitCommitEnvVars from libvcs.sync.git import GitSync @@ -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/test_pytest_plugin.py b/tests/test_pytest_plugin.py index aa0896a3..3c5e7abd 100644 --- a/tests/test_pytest_plugin.py +++ b/tests/test_pytest_plugin.py @@ -15,7 +15,7 @@ if t.TYPE_CHECKING: import pathlib - from libvcs.pytest_plugin import CreateRepoPytestFixtureFn + from libvcs.pytest_plugin import CreateRepoPytestFixtureFn, GitCommitEnvVars from libvcs.sync.git import GitSync @@ -265,7 +265,7 @@ def test_gitconfig_submodule_file_protocol( def test_git_repo_fixture_submodule_file_protocol( git_repo: GitSync, create_git_remote_repo: CreateRepoPytestFixtureFn, - git_commit_envvars: dict[str, str], + git_commit_envvars: GitCommitEnvVars, user_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch, ) -> None: From 0e3f63ceb10a2c18cc8e70a86a68cc2ac5f91caf Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 5 Apr 2026 20:01:18 -0500 Subject: [PATCH 2/9] pytest_plugin(refactor[types]): Rename CreateRepoPytestFixtureFn -> CreateRepoFn why: "PytestFixture" is redundant context inside pytest_plugin.py; the shorter name aligns with the existing Fn-suffix convention used by CreateRepoPostInitFn. what: - Rename Protocol class in src/libvcs/pytest_plugin.py - Update all import sites and type annotations across tests and README.md --- README.md | 4 ++-- src/libvcs/pytest_plugin.py | 24 +++++++++++------------ tests/cmd/test_git.py | 4 ++-- tests/sync/test_git.py | 38 ++++++++++++++++++------------------- tests/sync/test_hg.py | 4 ++-- tests/sync/test_svn.py | 6 +++--- tests/test_pytest_plugin.py | 14 +++++++------- 7 files changed, 47 insertions(+), 47 deletions(-) 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/src/libvcs/pytest_plugin.py b/src/libvcs/pytest_plugin.py index ba714033..b1f347c6 100644 --- a/src/libvcs/pytest_plugin.py +++ b/src/libvcs/pytest_plugin.py @@ -282,7 +282,7 @@ def __call__( ... -class CreateRepoPytestFixtureFn(t.Protocol): +class CreateRepoFn(t.Protocol): """Typing for VCS pytest fixture callback.""" def __call__( @@ -376,7 +376,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( @@ -405,7 +405,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( @@ -456,7 +456,7 @@ 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: GitCommitEnvVars, ) -> pathlib.Path: @@ -543,7 +543,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( @@ -573,7 +573,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() @@ -582,7 +582,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() @@ -649,7 +649,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( @@ -683,7 +683,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.""" @@ -791,9 +791,9 @@ def add_doctest_fixtures( set_home: pathlib.Path, 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 674bcceb..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, GitCommitEnvVars + 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, 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 3c5e7abd..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, GitCommitEnvVars + 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,7 +264,7 @@ 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, + create_git_remote_repo: CreateRepoFn, git_commit_envvars: GitCommitEnvVars, user_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch, From 8070809afd109889895b7e6c8bcb75a15cb8629a Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Mon, 6 Apr 2026 04:57:25 -0500 Subject: [PATCH 3/9] docs(pytest-plugin[types]): Register CreateRepoFn and CreateRepoPostInitFn for cross-referencing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: The fixture summary table rendered return types like `CreateRepoFn` as plain text instead of hyperlinks. sphinx_autodoc_pytest_fixtures emits :class:`~libvcs.pytest_plugin.CreateRepoFn` for every fixture returning that type, but Sphinx silently degrades to plain text when the name is absent from the Python domain's object index — which it was, since the Protocol classes had no autoclass directive anywhere. what: - Add a "Types" section to docs/api/pytest-plugin.md with autoclass directives for CreateRepoFn and CreateRepoPostInitFn - Use :special-members: __call__ to expose the callback contract - Use :exclude-members: __init__, _abc_impl, _is_protocol to suppress Protocol internals surfaced by gp-sphinx's global autodoc defaults (private-members: True, autodoc_class_signature: "separated") --- docs/api/pytest-plugin.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/docs/api/pytest-plugin.md b/docs/api/pytest-plugin.md index b5fc0e52..8940dcdd 100644 --- a/docs/api/pytest-plugin.md +++ b/docs/api/pytest-plugin.md @@ -41,3 +41,15 @@ def setup( pass ``` ::: + +## Types + +```{eval-rst} +.. 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 +``` From 9be0ac230a2402750f752e1bfe1cddccbd28fc20 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Mon, 6 Apr 2026 05:16:38 -0500 Subject: [PATCH 4/9] docs(CHANGES) Type improvements --- CHANGES | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) 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 From db71ed313bc6df402ea7c8666a30acd75f1cde17 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Mon, 6 Apr 2026 06:35:51 -0500 Subject: [PATCH 5/9] pytest_plugin(docs[GitCommitEnvVars]): Add docstring to GitCommitEnvVars TypeAlias why: sphinx's automodule :members: skips module-level TypeAliases whose value has a __module__ that differs from the documented module (e.g. dict[str, str] has __module__ = 'builtins'). Static source analysis via ModuleAnalyzer.attr_docs only picks up the member when a docstring is present. Without it, GitCommitEnvVars never enters the py:data inventory and cannot be cross-referenced from the fixture summary table. what: - Add one-line docstring to GitCommitEnvVars: TypeAlias so sphinx's static analyser includes it in automodule :members: output, registering it as a py:data entry in the Sphinx inventory --- src/libvcs/pytest_plugin.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/libvcs/pytest_plugin.py b/src/libvcs/pytest_plugin.py index b1f347c6..131688ff 100644 --- a/src/libvcs/pytest_plugin.py +++ b/src/libvcs/pytest_plugin.py @@ -267,6 +267,7 @@ 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 From db8e3d53963263d2e0b56ea1c74637716f5d95cd Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Mon, 6 Apr 2026 06:36:01 -0500 Subject: [PATCH 6/9] docs(pytest-plugin[types]): Register GitCommitEnvVars for cross-referencing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: The fixture summary table rendered by sphinx-autodoc-pytest-fixtures emits :class: cross-references for every fixture's return type. For GitCommitEnvVars (now a py:data entry after gaining a docstring) to resolve as a clickable link in that table, it must appear in the autodata directive that populates the ## Types section — the same pattern used for CreateRepoFn and CreateRepoPostInitFn. what: - Add .. autodata:: libvcs.pytest_plugin.GitCommitEnvVars at the top of the ## Types block so the type is both documented and reachable as an anchor from the fixture table link --- docs/api/pytest-plugin.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/api/pytest-plugin.md b/docs/api/pytest-plugin.md index 8940dcdd..d939dadf 100644 --- a/docs/api/pytest-plugin.md +++ b/docs/api/pytest-plugin.md @@ -45,6 +45,8 @@ def setup( ## Types ```{eval-rst} +.. autodata:: libvcs.pytest_plugin.GitCommitEnvVars + .. autoclass:: libvcs.pytest_plugin.CreateRepoFn :special-members: __call__ :exclude-members: __init__, _abc_impl, _is_protocol From ad6251ada2127b2d4f31875c4e2a948827c53ea5 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Mon, 6 Apr 2026 06:36:13 -0500 Subject: [PATCH 7/9] docs(conf): Add missing-reference handler to resolve py:data as :class: links why: sphinx-autodoc-pytest-fixtures wraps every fixture return-type identifier in a :class: cross-reference so that Sphinx and intersphinx can resolve it. Sphinx 8.x's Python domain only searches py:class and py:exception entries for :class: references; TypeAliases land in the inventory as py:data instead. The result is a broken (unlinked) type name in the fixture summary table for any TypeAlias return type. The same handler was added to unihan-etl's conf.py for the same reason; this brings libvcs in line with that approach. what: - Import typing as t at the top of conf.py - Guard docutils / sphinx type imports under TYPE_CHECKING so mypy gets accurate types without a runtime dependency on types-docutils being present in every environment - Add _on_missing_class_reference(): when a :class: reference cannot be resolved, fall back to searching all Python-domain objects so that py:data entries (TypeAliases) are found and linked - Add setup(app) to wire the handler into Sphinx's missing-reference event --- docs/conf.py | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/docs/conf.py b/docs/conf.py index 01aec4b9..bde59906 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -4,6 +4,13 @@ import pathlib import sys +import typing as t + +if t.TYPE_CHECKING: + from docutils import nodes + from sphinx import addnodes + from sphinx.application import Sphinx + from sphinx.environment import BuildEnvironment from gp_sphinx.config import make_linkcode_resolve, merge_sphinx_config @@ -54,3 +61,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 = env.get_domain("py") + target = node.get("reftarget", "") + matches = py_domain.find_obj(env, "", "", target, None, 1) + 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) From 4d795fe7d9e191b9fe392b996c783e086d8070a7 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Mon, 6 Apr 2026 06:41:27 -0500 Subject: [PATCH 8/9] docs(conf): Fix mypy attr-defined error on py_domain.find_obj why: env.get_domain() returns Domain, the base class, which does not declare find_obj(). mypy correctly flags this as an attr-defined error because find_obj is defined only on PythonDomain. what: - Import PythonDomain from sphinx.domains.python under TYPE_CHECKING - Annotate py_domain as PythonDomain with a type: ignore[assignment] comment to satisfy mypy while keeping the cast lightweight at runtime --- docs/conf.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/conf.py b/docs/conf.py index bde59906..d117f468 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -10,6 +10,7 @@ from docutils import nodes 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 @@ -73,7 +74,7 @@ def _on_missing_class_reference( return None from sphinx.util.nodes import make_refnode - py_domain = env.get_domain("py") + py_domain: PythonDomain = env.get_domain("py") # type: ignore[assignment] target = node.get("reftarget", "") matches = py_domain.find_obj(env, "", "", target, None, 1) if not matches: From 4260c5970591009a2c98fb007f7bff398b13c924 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Mon, 6 Apr 2026 06:42:25 -0500 Subject: [PATCH 9/9] docs(conf): Fix mypy errors for CI (uv run mypy .) --- docs/conf.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index d117f468..370165c3 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -7,7 +7,7 @@ import typing as t if t.TYPE_CHECKING: - from docutils import nodes + from docutils import nodes # type: ignore[import-untyped] from sphinx import addnodes from sphinx.application import Sphinx from sphinx.domains.python import PythonDomain @@ -76,7 +76,7 @@ def _on_missing_class_reference( py_domain: PythonDomain = env.get_domain("py") # type: ignore[assignment] target = node.get("reftarget", "") - matches = py_domain.find_obj(env, "", "", target, None, 1) + 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]