From 36392da6fcdf268b01234946e263fcae904558b6 Mon Sep 17 00:00:00 2001 From: Antti Kaihola <13725+akaihola@users.noreply.github.com> Date: Sun, 20 Oct 2024 19:52:46 +0200 Subject: [PATCH 01/12] feat: add a module-scope Git repository fixture and a Git repository factory This helps speed up tests, especially on Windows where forked processes are comically expensive. --- setup.cfg | 1 + src/darkgraylib/testtools/git_repo_plugin.py | 157 ++++++++++++------- src/darkgraylib/testtools/patching.py | 17 ++ 3 files changed, 117 insertions(+), 58 deletions(-) create mode 100644 src/darkgraylib/testtools/patching.py diff --git a/setup.cfg b/setup.cfg index 8f32d381..c34247b0 100644 --- a/setup.cfg +++ b/setup.cfg @@ -48,6 +48,7 @@ pygments.lexers = pytest11 = pytest_git_repo = darkgraylib.testtools.git_repo_plugin pytest_clear_black_cache = darkgraylib.testtools.clear_black_cache_plugin + pytest_patching = darkgraylib.testtools.patching [options.extras_require] color = diff --git a/src/darkgraylib/testtools/git_repo_plugin.py b/src/darkgraylib/testtools/git_repo_plugin.py index 66617ec3..d7885bd2 100644 --- a/src/darkgraylib/testtools/git_repo_plugin.py +++ b/src/darkgraylib/testtools/git_repo_plugin.py @@ -1,12 +1,19 @@ """Git repository fixture as a Pytest plugin""" +# pylint: disable=no-member # context managers misfire Pylint's member-checking + +from __future__ import annotations + import os import re +from contextlib import contextmanager from pathlib import Path +from shutil import rmtree from subprocess import check_call # nosec -from typing import Dict, Iterable, List, Union +from typing import Dict, Generator, Iterable, List, Union import pytest +from _pytest.tmpdir import _mk_tmp from darkgraylib.git import git_check_output_lines, git_get_version @@ -18,6 +25,47 @@ def __init__(self, root: Path, env: Dict[str, str]): self.root = root self.env = env + @classmethod + def tmp_repo( + cls, request: pytest.FixtureRequest, tmp_path_factory: pytest.TempPathFactory + ) -> Generator[GitRepoFixture]: + """Create temporary Git repository and change current working directory into it. + + This raw function needs to be turned into a fixture using `pytest.fixture` or a + context manager using `contextmanager`. + Examples:: + + git_repo = pytest.fixture(scope="module")(GitRepoFixture._tmp_repo) + def test_something(git_repo): + assert git_repo.root.is_dir() + + my_fixture = contextmanager(GitRepoFixture._tmp_repo) + def test_something_else(request, tmp_path_factory): + with my_fixture(request, tmp_path_factory) as git_repo: + assert git_repo.root.is_dir() + + """ + path = _mk_tmp(request, tmp_path_factory) + try: + with pytest.MonkeyPatch.context() as mp: + repository = cls.create_repository(path) + mp.chdir(repository.root) + # While `GitRepoFixture.create_repository()` already deletes `GIT_*` + # environment variables for any Git commands run by the fixture, let's + # explicitly remove `GIT_DIR` in case a test should call Git directly: + mp.delenv("GIT_DIR", raising=False) + yield repository + finally: + rmtree(path, ignore_errors=True) + + @classmethod + @contextmanager + def context( + cls, request: pytest.FixtureRequest, tmp_path_factory: pytest.TempPathFactory + ) -> Generator[GitRepoFixture]: + """Return a context manager for creating a temporary Git repository.""" + yield from cls.tmp_repo(request, tmp_path_factory) + @classmethod def create_repository(cls, root: Path) -> "GitRepoFixture": """Fixture method for creating a Git repository in the given directory""" @@ -120,22 +168,16 @@ def expand_root(self, lines: Iterable[str]) -> List[str]: ] -@pytest.fixture -def git_repo(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> GitRepoFixture: - """Create a temporary Git repository and change current working directory into it""" - repository = GitRepoFixture.create_repository(tmp_path) - monkeypatch.chdir(tmp_path) - # While `GitRepoFixture.create_repository()` already deletes `GIT_*` environment - # variables for any Git commands run by the fixture, let's explicitly remove - # `GIT_DIR` in case a test should call Git directly: - monkeypatch.delenv("GIT_DIR", raising=False) +git_repo = pytest.fixture(GitRepoFixture.tmp_repo) +git_repo_m = pytest.fixture(scope="module")(GitRepoFixture.tmp_repo) - return repository +def branched_repo( + request: pytest.FixtureRequest, tmp_path_factory: pytest.TempPathFactory +) -> Generator[GitRepoFixture]: + """Create an example Git repository with a master branch and a feature branch. -@pytest.fixture(scope="module") -def branched_repo(tmp_path_factory: pytest.TempPathFactory) -> GitRepoFixture: - """Create an example Git repository with a master branch and a feature branch + This raw function needs to be turned into a fixture using `pytest.fixture`. The history created is:: @@ -147,47 +189,46 @@ def branched_repo(tmp_path_factory: pytest.TempPathFactory) -> GitRepoFixture: * Initial commit """ - tmpdir = tmp_path_factory.mktemp("branched_repo") - repo = GitRepoFixture.create_repository(tmpdir) - repo.add( - { - "del_master.py": "original", - "del_branch.py": "original", - "del_index.py": "original", - "del_worktree.py": "original", - "mod_master.py": "original", - "mod_branch.py": "original", - "mod_both.py": "original", - "mod_same.py": "original", - "keep.py": "original", - }, - commit="Initial commit", - ) - branch_point = repo.get_hash() - repo.add( - { - "del_master.py": None, - "add_master.py": "master", - "mod_master.py": "master", - "mod_both.py": "master", - "mod_same.py": "same", - }, - commit="master", - ) - repo.create_branch("branch", branch_point) - repo.add( - { - "del_branch.py": None, - "mod_branch.py": "branch", - "mod_both.py": "branch", - "mod_same.py": "same", - }, - commit="branch", - ) - repo.add( - {"del_index.py": None, "add_index.py": "index", "mod_index.py": "index"} - ) - (repo.root / "del_worktree.py").unlink() - (repo.root / "add_worktree.py").write_bytes(b"worktree") - (repo.root / "mod_worktree.py").write_bytes(b"worktree") - return repo + with GitRepoFixture.context(request, tmp_path_factory) as repo: + repo.add( + { + "del_master.py": "original", + "del_branch.py": "original", + "del_index.py": "original", + "del_worktree.py": "original", + "mod_master.py": "original", + "mod_branch.py": "original", + "mod_both.py": "original", + "mod_same.py": "original", + "keep.py": "original", + }, + commit="Initial commit", + ) + branch_point = repo.get_hash() + repo.add( + { + "del_master.py": None, + "add_master.py": "master", + "mod_master.py": "master", + "mod_both.py": "master", + "mod_same.py": "same", + }, + commit="master", + ) + repo.create_branch("branch", branch_point) + repo.add( + { + "del_branch.py": None, + "mod_branch.py": "branch", + "mod_both.py": "branch", + "mod_same.py": "same", + }, + commit="branch", + ) + repo.add( + {"del_index.py": None, "add_index.py": "index", "mod_index.py": "index"} + ) + (repo.root / "del_worktree.py").unlink() + (repo.root / "add_worktree.py").write_bytes(b"worktree") + (repo.root / "mod_worktree.py").write_bytes(b"worktree") + yield repo diff --git a/src/darkgraylib/testtools/patching.py b/src/darkgraylib/testtools/patching.py new file mode 100644 index 00000000..f47d1b8a --- /dev/null +++ b/src/darkgraylib/testtools/patching.py @@ -0,0 +1,17 @@ +"""Helpers for patching in tests.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest + +if TYPE_CHECKING: + from typing import Iterator + + +@pytest.fixture(scope="module") +def monkeymodule() -> Iterator[pytest.MonkeyPatch]: + """Return a module-scope monkeypatch fixture.""" + with pytest.MonkeyPatch.context() as monkey_patch: + yield monkey_patch From d14f3d3736313b3d944400162be6e1d62ffee694 Mon Sep 17 00:00:00 2001 From: Antti Kaihola <13725+akaihola@users.noreply.github.com> Date: Sun, 3 Nov 2024 21:16:04 +0200 Subject: [PATCH 02/12] style: modernize typing in git_repo_plugin.py --- src/darkgraylib/testtools/git_repo_plugin.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/darkgraylib/testtools/git_repo_plugin.py b/src/darkgraylib/testtools/git_repo_plugin.py index d7885bd2..27c7636e 100644 --- a/src/darkgraylib/testtools/git_repo_plugin.py +++ b/src/darkgraylib/testtools/git_repo_plugin.py @@ -10,7 +10,7 @@ from pathlib import Path from shutil import rmtree from subprocess import check_call # nosec -from typing import Dict, Generator, Iterable, List, Union +from typing import Generator, Iterable import pytest from _pytest.tmpdir import _mk_tmp @@ -21,7 +21,7 @@ class GitRepoFixture: """Fixture for managing temporary Git repositories""" - def __init__(self, root: Path, env: Dict[str, str]): + def __init__(self, root: Path, env: dict[str, str]) -> None: self.root = root self.env = env @@ -67,7 +67,7 @@ def context( yield from cls.tmp_repo(request, tmp_path_factory) @classmethod - def create_repository(cls, root: Path) -> "GitRepoFixture": + def create_repository(cls, root: Path) -> GitRepoFixture: """Fixture method for creating a Git repository in the given directory""" # For testing, ignore ~/.gitconfig settings like templateDir and defaultBranch. # Also, this makes sure GIT_DIR or other GIT_* variables are not set, and that @@ -92,8 +92,10 @@ def _run_and_get_first_line(self, *args: str) -> str: return git_check_output_lines(list(args), Path(self.root))[0] def add( - self, paths_and_contents: Dict[str, Union[str, bytes, None]], commit: str = None - ) -> Dict[str, Path]: + self, + paths_and_contents: dict[str, str | bytes | None], + commit: str | None = None, + ) -> dict[str, Path]: """Add/remove/modify files and optionally commit the changes :param paths_and_contents: Paths of the files relative to repository root, and @@ -148,7 +150,7 @@ def create_branch(self, new_branch: str, start_point: str) -> None: """Fixture method to create and check out new branch at given starting point""" self._run("checkout", "-b", new_branch, start_point) - def expand_root(self, lines: Iterable[str]) -> List[str]: + def expand_root(self, lines: Iterable[str]) -> list[str]: """Replace "{root/}" in strings with the path in the temporary Git repo This is used to generate expected strings corresponding to locations of files in From 3fbe4f20d8a009ec944730a55253569c185c4c59 Mon Sep 17 00:00:00 2001 From: Antti Kaihola <13725+akaihola@users.noreply.github.com> Date: Sun, 3 Nov 2024 21:17:12 +0200 Subject: [PATCH 03/12] docs: add missing docstring in git_repo_plugin.py --- src/darkgraylib/testtools/git_repo_plugin.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/darkgraylib/testtools/git_repo_plugin.py b/src/darkgraylib/testtools/git_repo_plugin.py index 27c7636e..a6f924d9 100644 --- a/src/darkgraylib/testtools/git_repo_plugin.py +++ b/src/darkgraylib/testtools/git_repo_plugin.py @@ -22,6 +22,7 @@ class GitRepoFixture: """Fixture for managing temporary Git repositories""" def __init__(self, root: Path, env: dict[str, str]) -> None: + """Use given environment, and directory as the root of the Git repository.""" self.root = root self.env = env From 575ade06135fd2f9867caebf4a23430f2659b237 Mon Sep 17 00:00:00 2001 From: Antti Kaihola <13725+akaihola@users.noreply.github.com> Date: Sun, 3 Nov 2024 14:20:36 +0200 Subject: [PATCH 04/12] feat: speed up `git_check_output_lines` test --- src/darkgraylib/tests/test_git.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/src/darkgraylib/tests/test_git.py b/src/darkgraylib/tests/test_git.py index ca84c048..7a2a4b94 100644 --- a/src/darkgraylib/tests/test_git.py +++ b/src/darkgraylib/tests/test_git.py @@ -1,3 +1,8 @@ +"""Tests for the `darkgraylib.git` module.""" + +# pylint: disable=redefined-outer-name # fixtures misfire Pylint's redefinition checks +# pylint: disable=use-dict-literal # dict() ok with kwparametrize + import os import re from datetime import datetime, timedelta @@ -9,7 +14,7 @@ import pytest from darkgraylib import git -from darkgraylib.testtools.git_repo_plugin import GitRepoFixture +from darkgraylib.testtools.git_repo_plugin import GitRepoFixture, branched_repo from darkgraylib.testtools.helpers import raises_or_matches from darkgraylib.utils import GIT_DATEFORMAT, TextDocument @@ -177,6 +182,9 @@ def test_git_get_content_at_revision_obtain_file_content( assert text_document_class.method_calls == expect_textdocument_calls +git_check_output_lines_repo = pytest.fixture(scope="module")(branched_repo) + + @pytest.mark.kwparametrize( dict(cmd=[], exit_on_error=True, expect_template=CalledProcessError(1, "")), dict( @@ -227,8 +235,11 @@ def test_git_get_content_at_revision_obtain_file_content( expect_template=CalledProcessError(128, ""), ), ) -def test_git_check_output_lines(branched_repo, cmd, exit_on_error, expect_template): +def test_git_check_output_lines( + git_check_output_lines_repo, cmd, exit_on_error, expect_template +): """Unit test for :func:`git_check_output_lines`""" + branched_repo = git_check_output_lines_repo if isinstance(expect_template, BaseException): expect: Union[List[str], BaseException] = expect_template else: From 4eb4a2b3afbf63a3c7c10c859669fb00fd7bd2e1 Mon Sep 17 00:00:00 2001 From: Antti Kaihola <13725+akaihola@users.noreply.github.com> Date: Mon, 21 Oct 2024 21:58:37 +0200 Subject: [PATCH 05/12] test: speed up `git_clone_local_branch` test --- src/darkgraylib/tests/test_git.py | 28 ++++++++++++++++++---------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/src/darkgraylib/tests/test_git.py b/src/darkgraylib/tests/test_git.py index 7a2a4b94..14503bb8 100644 --- a/src/darkgraylib/tests/test_git.py +++ b/src/darkgraylib/tests/test_git.py @@ -1,5 +1,6 @@ """Tests for the `darkgraylib.git` module.""" +# pylint: disable=no-member # context managers misfire Pylint's member-checking # pylint: disable=redefined-outer-name # fixtures misfire Pylint's redefinition checks # pylint: disable=use-dict-literal # dict() ok with kwparametrize @@ -367,22 +368,29 @@ def test_git_get_content_at_revision_encoding(encodings_repo, commit, encoding, assert result.lines == lines +@pytest.fixture(scope="module") +def git_clone_local_branch_repo(request, tmp_path_factory): + """Git repository with three branches and a file with different content in each.""" + with GitRepoFixture.context(request, tmp_path_factory) as repo: + repo.add({"a.py": "first"}, commit="first") + repo.create_branch("first", "HEAD") + repo.create_branch("second", "HEAD") + repo.add({"a.py": "second"}, commit="second") + repo.create_branch("third", "HEAD") + repo.add({"a.py": "third"}, commit="third") + yield repo + + @pytest.mark.kwparametrize( dict(branch="first", expect="first"), dict(branch="second", expect="second"), dict(branch="third", expect="third"), dict(branch="HEAD", expect="third"), ) -def test_git_clone_local_branch(git_repo, tmp_path, branch, expect): - """``git_clone_local()`` checks out the specified branch""" - git_repo.add({"a.py": "first"}, commit="first") - git_repo.create_branch("first", "HEAD") - git_repo.create_branch("second", "HEAD") - git_repo.add({"a.py": "second"}, commit="second") - git_repo.create_branch("third", "HEAD") - git_repo.add({"a.py": "third"}, commit="third") - - with git.git_clone_local(git_repo.root, branch, tmp_path / "clone") as clone: +def test_git_clone_local_branch(git_clone_local_branch_repo, tmp_path, branch, expect): + """`git_clone_local` checks out the specified branch.""" + repo = git_clone_local_branch_repo + with git.git_clone_local(repo.root, branch, tmp_path / "clone") as clone: assert (clone / "a.py").read_text() == expect From 89cfc70e3adbb22ba69985446aa39ee7b756aae4 Mon Sep 17 00:00:00 2001 From: Antti Kaihola <13725+akaihola@users.noreply.github.com> Date: Mon, 21 Oct 2024 22:09:31 +0200 Subject: [PATCH 06/12] test: speed up `git_get_content_at_revision` test --- src/darkgraylib/tests/test_git.py | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/src/darkgraylib/tests/test_git.py b/src/darkgraylib/tests/test_git.py index 14503bb8..fb09411c 100644 --- a/src/darkgraylib/tests/test_git.py +++ b/src/darkgraylib/tests/test_git.py @@ -79,6 +79,17 @@ def test_git_get_mtime_at_commit(): assert result == "2020-12-27 21:33:59.000000 +0000" +@pytest.fixture(scope="module") +def git_get_content_at_revision_repo(request, tmp_path_factory): + """Return Git repository fixture with a file that changes over time.""" + with GitRepoFixture.context(request, tmp_path_factory) as repo: + repo.add({"my.txt": "original content"}, commit="Initial commit") + paths = repo.add({"my.txt": "modified content"}, commit="Second commit") + paths["my.txt"].write_bytes(b"new content") + os.utime(paths["my.txt"], (1000000000, 1000000000)) + yield repo + + @pytest.mark.kwparametrize( dict( revision=":WORKTREE:", @@ -97,15 +108,12 @@ def test_git_get_mtime_at_commit(): ), dict(revision="HEAD~2", expect_lines=(), expect_mtime=False), ) -def test_git_get_content_at_revision(git_repo, revision, expect_lines, expect_mtime): - """darkgraylib.git.git_get_content_at_revision()""" - git_repo.add({"my.txt": "original content"}, commit="Initial commit") - paths = git_repo.add({"my.txt": "modified content"}, commit="Initial commit") - paths["my.txt"].write_bytes(b"new content") - os.utime(paths["my.txt"], (1000000000, 1000000000)) - +def test_git_get_content_at_revision( + git_get_content_at_revision_repo, revision, expect_lines, expect_mtime +): + """Test for `git.git_get_content_at_revision`.""" result = git.git_get_content_at_revision( - Path("my.txt"), revision, cwd=Path(git_repo.root) + Path("my.txt"), revision, cwd=Path(git_get_content_at_revision_repo.root) ) assert result.lines == expect_lines From 20e52dfe4852f6043433d87198c1655ab1f12555 Mon Sep 17 00:00:00 2001 From: Antti Kaihola <13725+akaihola@users.noreply.github.com> Date: Mon, 21 Oct 2024 22:24:17 +0200 Subject: [PATCH 07/12] test: speed up `git_clone_local_command` test --- src/darkgraylib/tests/test_git.py | 69 +++++++++++++++++++++---------- 1 file changed, 47 insertions(+), 22 deletions(-) diff --git a/src/darkgraylib/tests/test_git.py b/src/darkgraylib/tests/test_git.py index fb09411c..97a0905e 100644 --- a/src/darkgraylib/tests/test_git.py +++ b/src/darkgraylib/tests/test_git.py @@ -9,6 +9,7 @@ from datetime import datetime, timedelta from pathlib import Path from subprocess import PIPE, CalledProcessError # nosec +from types import SimpleNamespace from typing import List, Union from unittest.mock import ANY, Mock, call, patch @@ -402,34 +403,58 @@ def test_git_clone_local_branch(git_clone_local_branch_repo, tmp_path, branch, e assert (clone / "a.py").read_text() == expect +@pytest.fixture(scope="module") +def git_clone_local_command_fixture(request, tmp_path_factory): + """Repository and other fixtures for `git.git_clone_local` tests.""" + with GitRepoFixture.context(request, tmp_path_factory) as repo: + fixture = SimpleNamespace() + fixture.root = repo.root + repo.add({"a.py": "first"}, commit="first") + repo.create_branch("mybranch", "HEAD") + fixture.check_output = Mock( + wraps=git.check_output # type: ignore[attr-defined] + ) + temporary_path = tmp_path_factory.mktemp("git_clone_local_command") + fixture.clone = temporary_path / "clone" + fixture.check_output_opts = dict( + cwd=str(repo.root), encoding=None, stderr=PIPE, env=ANY + ) + fixture.post_call = call( + ["git", "worktree", "remove", "--force", "--force", str(fixture.clone)], + **fixture.check_output_opts, + ) + yield fixture + + @pytest.mark.kwparametrize( dict(branch="HEAD"), dict(branch="mybranch"), ) -def test_git_clone_local_command(git_repo, tmp_path, branch): - """``git_clone_local()`` issues the correct Git command and options""" - git_repo.add({"a.py": "first"}, commit="first") - git_repo.create_branch("mybranch", "HEAD") - check_output = Mock(wraps=git.check_output) # type: ignore[attr-defined] - clone = tmp_path / "clone" - check_output_opts = dict( - cwd=str(git_repo.root), encoding=None, stderr=PIPE, env=ANY - ) +def test_git_clone_local_command(git_clone_local_command_fixture, branch): + """`git.git_clone_local` issues the correct Git command and options.""" + fixture = git_clone_local_command_fixture pre_call = call( - ["git", "worktree", "add", "--quiet", "--force", "--force", str(clone), branch], - **check_output_opts, - ) - post_call = call( - ["git", "worktree", "remove", "--force", "--force", str(clone)], - **check_output_opts, + [ + "git", + "worktree", + "add", + "--quiet", + "--force", + "--force", + str(fixture.clone), + branch, + ], + **fixture.check_output_opts, ) - with patch.object(git, "check_output", check_output): - with git.git_clone_local(git_repo.root, branch, clone) as result: - assert result == clone - - check_output.assert_has_calls([pre_call]) - check_output.reset_mock() - check_output.assert_has_calls([post_call]) + with patch.object(git, "check_output", fixture.check_output), git.git_clone_local( + fixture.root, branch, fixture.clone + ) as result: + # function called, begin assertions + + assert result == fixture.clone + fixture.check_output.assert_has_calls([pre_call]) + fixture.check_output.reset_mock() + fixture.check_output.assert_has_calls([fixture.post_call]) @pytest.mark.parametrize( From 7801d8c0796dceb19529496991df9401a47c30c1 Mon Sep 17 00:00:00 2001 From: Antti Kaihola <13725+akaihola@users.noreply.github.com> Date: Mon, 21 Oct 2024 22:27:02 +0200 Subject: [PATCH 08/12] test: speed up `git_get_root` test --- src/darkgraylib/tests/test_git.py | 32 ++++++++++++++++++------------- 1 file changed, 19 insertions(+), 13 deletions(-) diff --git a/src/darkgraylib/tests/test_git.py b/src/darkgraylib/tests/test_git.py index 97a0905e..8730050d 100644 --- a/src/darkgraylib/tests/test_git.py +++ b/src/darkgraylib/tests/test_git.py @@ -457,6 +457,21 @@ def test_git_clone_local_command(git_clone_local_command_fixture, branch): fixture.check_output.assert_has_calls([fixture.post_call]) +@pytest.fixture(scope="module") +def git_get_root_repo(request, tmp_path_factory): + """Make a Git repository with files in the root and in subdirectories.""" + with GitRepoFixture.context(request, tmp_path_factory) as repo: + repo.add( + { + "root.py": "root", + "subdir/sub.py": "sub", + "subdir/subsubdir/subsub.py": "subsub", + }, + commit="Initial commit", + ) + yield repo + + @pytest.mark.parametrize( "path", [ @@ -468,20 +483,11 @@ def test_git_clone_local_command(git_clone_local_command_fixture, branch): "subdir/subsubdir/subsub.py", ], ) -def test_git_get_root(git_repo, path): - """``git_get_root()`` returns repository root for any file or directory inside""" - git_repo.add( - { - "root.py": "root", - "subdir/sub.py": "sub", - "subdir/subsubdir/subsub.py": "subsub", - }, - commit="Initial commit", - ) - - root = git.git_get_root(git_repo.root / path) +def test_git_get_root(git_get_root_repo, path): + """`git.git_get_root` returns repository root for any file or directory inside.""" + root = git.git_get_root(git_get_root_repo.root / path) - assert root == git_repo.root + assert root == git_get_root_repo.root @pytest.mark.parametrize( From 774a3271bbbba7bd17e2413fbf2138ce34bb41de Mon Sep 17 00:00:00 2001 From: Antti Kaihola <13725+akaihola@users.noreply.github.com> Date: Mon, 21 Oct 2024 22:34:30 +0200 Subject: [PATCH 09/12] test: speed up `git_check_output_lines_stderr_and_log` and `git_get_content_at_revision` tests --- src/darkgraylib/tests/test_git.py | 44 ++++++++++++++++++++----------- 1 file changed, 28 insertions(+), 16 deletions(-) diff --git a/src/darkgraylib/tests/test_git.py b/src/darkgraylib/tests/test_git.py index 8730050d..d4885637 100644 --- a/src/darkgraylib/tests/test_git.py +++ b/src/darkgraylib/tests/test_git.py @@ -259,6 +259,18 @@ def test_git_check_output_lines( check(git.git_check_output_lines(cmd, branched_repo.root, exit_on_error)) +@pytest.fixture(scope="module") +def two_file_repo(request, tmp_path_factory): + """Make a Git repo with two files in the root, and the hash of the first commit.""" + with GitRepoFixture.context(request, tmp_path_factory) as repo: + fixture = SimpleNamespace() + repo.add({"file1": "file1"}, commit="Initial commit") + fixture.initial = repo.get_hash()[:7] + repo.add({"file2": "file2"}, commit="Second commit") + fixture.root = repo.root + yield fixture + + @pytest.mark.kwparametrize( dict( cmd=["show", "{initial}:/.file2"], @@ -289,32 +301,32 @@ def test_git_check_output_lines( expect_log=r"$", ) def test_git_check_output_lines_stderr_and_log( - git_repo, capfd, caplog, cmd, exit_on_error, expect_exc, expect_stderr, expect_log + two_file_repo, + capfd, + caplog, + cmd, + exit_on_error, + expect_exc, + expect_stderr, + expect_log, ): """Git non-existing file error is logged and suppressed from stderr""" - git_repo.add({"file1": "file1"}, commit="Initial commit") - initial = git_repo.get_hash()[:7] - git_repo.add({"file2": "file2"}, commit="Second commit") - capfd.readouterr() # flush captured stdout and stderr - cmdline = [s.format(initial=initial) for s in cmd] + cmdline = [s.format(initial=two_file_repo.initial) for s in cmd] with pytest.raises(expect_exc): - git.git_check_output_lines(cmdline, git_repo.root, exit_on_error) + git.git_check_output_lines(cmdline, two_file_repo.root, exit_on_error) outerr = capfd.readouterr() assert outerr.out == "" assert outerr.err == expect_stderr - expect_log_re = expect_log.format(initial=initial) + expect_log_re = expect_log.format(initial=two_file_repo.initial) assert re.search(expect_log_re, caplog.text), repr(caplog.text) -def test_git_get_content_at_revision_stderr(git_repo, capfd, caplog): - """No stderr or log output from ``git_get_content_at_revision`` for missing file""" - git_repo.add({"file1": "file1"}, commit="Initial commit") - initial = git_repo.get_hash()[:7] - git_repo.add({"file2": "file2"}, commit="Second commit") - capfd.readouterr() # flush captured stdout and stderr - - result = git.git_get_content_at_revision(Path("file2"), initial, git_repo.root) +def test_git_get_content_at_revision_stderr(two_file_repo, capfd, caplog): + """No stderr/log output from `git.git_get_content_at_revision` for missing file.""" + result = git.git_get_content_at_revision( + Path("file2"), two_file_repo.initial, two_file_repo.root + ) assert result == TextDocument() outerr = capfd.readouterr() From 8a2a796bbcc138bc66d479a29bb654a5c204ac6a Mon Sep 17 00:00:00 2001 From: Antti Kaihola <13725+akaihola@users.noreply.github.com> Date: Sun, 20 Oct 2024 19:54:00 +0200 Subject: [PATCH 10/12] docs: update the change log --- CHANGES.rst | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGES.rst b/CHANGES.rst index 256e4d6c..4b884659 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -5,6 +5,10 @@ These features will be included in the next release: Added ----- +- Tools for easy creation of differently scoped Git repository fixtures for tests. + Helps speed up parameterized tests that need a Git repository, especially on Windows + where Git process forks are comically expensive. The ``test_git.py`` test module now + makes use of this and runs in 9s instead of 18s on one Windows laptop. - Unit tests of configuration file options for `darkgraylib.config.load_config`. Fixed From 9007892cf445f9b19c2859422ba8b7c9726e6b20 Mon Sep 17 00:00:00 2001 From: Antti Kaihola <13725+akaihola@users.noreply.github.com> Date: Sun, 3 Nov 2024 21:18:15 +0200 Subject: [PATCH 11/12] test: add required ruff ignores for Black compatibility --- pyproject.toml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 67dee844..be804f50 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,6 +41,7 @@ target-version = "py38" select = ["ALL"] ignore = [ "ANN101", # Missing type annotation for `self` in method + "COM812", # Trailing comma missing "D203", # One blank line required before class docstring "D213", # Multi-line docstring summary should start at the second line "D400", # First line should end with a period (duplicates D415) @@ -51,6 +52,7 @@ ignore = [ "ANN001", # Missing type annotation for function argument "ANN201", # Missing return type annotation for public function "ANN204", # Missing return type annotation for special method `__init__` + "INP001", # File is part of an implicit namespace package. Add an `__init__.py`. "C408", # Unnecessary `dict` call (rewrite as a literal) "S101", # Use of `assert` detected ] From 218e098b4e32f11991cad501ecdfbfbdbfaa78df Mon Sep 17 00:00:00 2001 From: Antti Kaihola <13725+akaihola@users.noreply.github.com> Date: Sat, 16 Nov 2024 20:40:04 +0200 Subject: [PATCH 12/12] feat: factory fixture for creating temporary copies of directory trees --- setup.cfg | 1 + src/darkgraylib/testtools/temp_copy.py | 31 ++++++++++++++++++++++++++ 2 files changed, 32 insertions(+) create mode 100644 src/darkgraylib/testtools/temp_copy.py diff --git a/setup.cfg b/setup.cfg index c34247b0..7a08168b 100644 --- a/setup.cfg +++ b/setup.cfg @@ -48,6 +48,7 @@ pygments.lexers = pytest11 = pytest_git_repo = darkgraylib.testtools.git_repo_plugin pytest_clear_black_cache = darkgraylib.testtools.clear_black_cache_plugin + pytest_temp_copy = darkgraylib.testtools.temp_copy pytest_patching = darkgraylib.testtools.patching [options.extras_require] diff --git a/src/darkgraylib/testtools/temp_copy.py b/src/darkgraylib/testtools/temp_copy.py new file mode 100644 index 00000000..32cbb417 --- /dev/null +++ b/src/darkgraylib/testtools/temp_copy.py @@ -0,0 +1,31 @@ +"""Pytest fixture factory for making temporary copies of directory trees.""" + +from __future__ import annotations + +import re +from contextlib import contextmanager +from shutil import copytree +from typing import TYPE_CHECKING, Callable, ContextManager + +import pytest + +if TYPE_CHECKING: + from pathlib import Path + from typing import Generator + + +@pytest.fixture +def make_temp_copy( + request: pytest.FixtureRequest, tmp_path_factory: pytest.TempPathFactory +) -> Callable[[Path], ContextManager[Path]]: + """Pytest fixture to create a temporary clone of a directory structure.""" + + @contextmanager + def temp_copy_factory(path: Path) -> Generator[Path]: + max_len = 30 + name = re.sub(r"\W", "_", f"clone_{request.node.name}")[:max_len] + clone = tmp_path_factory.mktemp(name, numbered=True) / path.name + copytree(path, clone) + yield clone + + return temp_copy_factory