From 64972d2c4d004a68bb0e942c78c7ac5f8509249d Mon Sep 17 00:00:00 2001 From: Shawn Hartsock Date: Sat, 6 Jun 2026 16:14:54 -0400 Subject: [PATCH 1/3] add python test infrastructure Refs #20 Co-authored-by: Codex --- .githooks/pre-push | 8 +++-- .github/workflows/ci.yml | 4 +-- .gitignore | 3 ++ pyproject.toml | 24 +++++++++++++++ python/tests/conftest.py | 51 +++++++++++++++++++++++++++++++ python/tests/test_e2e.py | 3 ++ python/tests/test_mock_fixture.py | 26 ++++++++++++++++ 7 files changed, 115 insertions(+), 4 deletions(-) create mode 100644 python/tests/conftest.py create mode 100644 python/tests/test_mock_fixture.py diff --git a/.githooks/pre-push b/.githooks/pre-push index 4f83ec2..78b1cc7 100755 --- a/.githooks/pre-push +++ b/.githooks/pre-push @@ -26,9 +26,13 @@ cargo build --features extension-module # Python E2E (mirrors the ci.yml `python-e2e` job). Best-effort: needs a local # .venv with maturin installed. CI is the authoritative gate. if [ -x .venv/bin/maturin ]; then - echo "[pre-push] maturin develop + Python E2E vs git" + echo "[pre-push] maturin develop + pytest coverage" .venv/bin/maturin develop --release >/dev/null - .venv/bin/python python/tests/test_e2e.py + .venv/bin/python -m pytest python/tests/ --cov=gitxtend --cov-fail-under=80 +elif [ -x .venv/Scripts/maturin.exe ]; then + echo "[pre-push] maturin develop + pytest coverage" + .venv/Scripts/maturin.exe develop --release >/dev/null + .venv/Scripts/python.exe -m pytest python/tests/ --cov=gitxtend --cov-fail-under=80 else echo "[pre-push] (skipping Python E2E — no .venv/bin/maturin; gated by CI python-e2e)" fi diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2c86634..4eda8aa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -53,6 +53,6 @@ jobs: - name: build wheel + run the E2E suite (the compiled module vs the real git CLI) run: | python -m venv .venv - .venv/bin/pip install -q maturin + .venv/bin/pip install -q ".[dev]" maturin .venv/bin/maturin develop --release - .venv/bin/python python/tests/test_e2e.py + .venv/bin/python -m pytest python/tests/ --cov=gitxtend --cov-fail-under=80 diff --git a/.gitignore b/.gitignore index ac87805..78764b9 100644 --- a/.gitignore +++ b/.gitignore @@ -7,11 +7,14 @@ Cargo.lock __pycache__/ *.py[cod] *.so +*.pdb .venv/ build/ dist/ *.egg-info/ wheels/ +.coverage +htmlcov/ # Editor / OS .DS_Store diff --git a/pyproject.toml b/pyproject.toml index e6a7927..ff27e9a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,6 +19,13 @@ classifiers = [ [project.urls] Repository = "https://github.com/hartsock/gitxtend" +[project.optional-dependencies] +dev = [ + "pytest>=8,<9", + "pytest-cov>=6,<7", + "pytest-mock>=3,<4", +] + [tool.maturin] # Compiled module imported as `import gitxtend`. Type stubs in python/gitxtend. # `extension-module` is a crate feature that layers on `python` (which pulls @@ -27,3 +34,20 @@ Repository = "https://github.com/hartsock/gitxtend" features = ["extension-module"] python-source = "python" module-name = "gitxtend._gitxtend" + +[tool.pytest.ini_options] +addopts = [ + "--strict-markers", +] +markers = [ + "integration: tests that exercise the compiled extension and real git CLI", +] +testpaths = ["python/tests"] + +[tool.coverage.run] +branch = true +source = ["gitxtend"] +omit = ["python/tests/*"] + +[tool.coverage.report] +fail_under = 80 diff --git a/python/tests/conftest.py b/python/tests/conftest.py new file mode 100644 index 0000000..8cafc35 --- /dev/null +++ b/python/tests/conftest.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +from unittest.mock import MagicMock + +import pytest + + +PUBLIC_EXTENSION_NAMES = ( + "RepoStatus", + "ahead_behind", + "current_branch", + "fetch", + "head_sha", + "is_clean", + "is_git_repo", + "last_commit_date", + "log_subjects", + "remote_head_sha", + "remote_urls", + "repo_status", + "rev_list_count", + "status_counts", + "tracking_branch", + "pull", + "push", + "add", + "commit", + "stash_push", + "stash_pop", + "create_branch", + "reset_hard", + "rebase", + "stash_rebase", +) + + +@pytest.fixture +def mock_gitxtend(monkeypatch): + """Patch the compiled extension surface for Python unit tests.""" + import gitxtend + import gitxtend._gitxtend as extension + + mock_extension = MagicMock(name="gitxtend._gitxtend") + + for name in PUBLIC_EXTENSION_NAMES: + mock = MagicMock(name=f"gitxtend._gitxtend.{name}") + setattr(mock_extension, name, mock) + monkeypatch.setattr(extension, name, mock, raising=False) + monkeypatch.setattr(gitxtend, name, mock, raising=False) + + return mock_extension diff --git a/python/tests/test_e2e.py b/python/tests/test_e2e.py index ec022f1..9a17163 100644 --- a/python/tests/test_e2e.py +++ b/python/tests/test_e2e.py @@ -17,6 +17,9 @@ import unittest import gitxtend +import pytest + +pytestmark = pytest.mark.integration _ENV = { **os.environ, diff --git a/python/tests/test_mock_fixture.py b/python/tests/test_mock_fixture.py new file mode 100644 index 0000000..b6169a2 --- /dev/null +++ b/python/tests/test_mock_fixture.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +import gitxtend + + +def test_mock_gitxtend_fixture_replaces_extension_exports(mock_gitxtend): + mock_gitxtend.is_git_repo.return_value = True + + assert gitxtend.is_git_repo("/tmp/repo") is True + mock_gitxtend.is_git_repo.assert_called_once_with("/tmp/repo") + + +def test_mock_gitxtend_fixture_includes_write_side_exports(mock_gitxtend): + for name in ( + "pull", + "push", + "add", + "commit", + "stash_push", + "stash_pop", + "create_branch", + "reset_hard", + "rebase", + "stash_rebase", + ): + assert hasattr(mock_gitxtend, name) From 84395b30bc8a2ef1e4853181e3146bcb9997b91b Mon Sep 17 00:00:00 2001 From: Shawn Hartsock Date: Sat, 6 Jun 2026 17:17:24 -0400 Subject: [PATCH 2/3] Address Python test infra review Co-authored-by: Codex --- .githooks/pre-push | 4 ++-- .github/workflows/ci.yml | 4 ++-- python/tests/test_mock_fixture.py | 31 ++++++++++++++++++------------- 3 files changed, 22 insertions(+), 17 deletions(-) diff --git a/.githooks/pre-push b/.githooks/pre-push index 78b1cc7..e18b142 100755 --- a/.githooks/pre-push +++ b/.githooks/pre-push @@ -27,11 +27,11 @@ cargo build --features extension-module # .venv with maturin installed. CI is the authoritative gate. if [ -x .venv/bin/maturin ]; then echo "[pre-push] maturin develop + pytest coverage" - .venv/bin/maturin develop --release >/dev/null + .venv/bin/maturin develop --release --extras dev >/dev/null .venv/bin/python -m pytest python/tests/ --cov=gitxtend --cov-fail-under=80 elif [ -x .venv/Scripts/maturin.exe ]; then echo "[pre-push] maturin develop + pytest coverage" - .venv/Scripts/maturin.exe develop --release >/dev/null + .venv/Scripts/maturin.exe develop --release --extras dev >/dev/null .venv/Scripts/python.exe -m pytest python/tests/ --cov=gitxtend --cov-fail-under=80 else echo "[pre-push] (skipping Python E2E — no .venv/bin/maturin; gated by CI python-e2e)" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4eda8aa..bc9137f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -53,6 +53,6 @@ jobs: - name: build wheel + run the E2E suite (the compiled module vs the real git CLI) run: | python -m venv .venv - .venv/bin/pip install -q ".[dev]" maturin - .venv/bin/maturin develop --release + .venv/bin/pip install -q maturin + .venv/bin/maturin develop --release --extras dev .venv/bin/python -m pytest python/tests/ --cov=gitxtend --cov-fail-under=80 diff --git a/python/tests/test_mock_fixture.py b/python/tests/test_mock_fixture.py index b6169a2..990020d 100644 --- a/python/tests/test_mock_fixture.py +++ b/python/tests/test_mock_fixture.py @@ -11,16 +11,21 @@ def test_mock_gitxtend_fixture_replaces_extension_exports(mock_gitxtend): def test_mock_gitxtend_fixture_includes_write_side_exports(mock_gitxtend): - for name in ( - "pull", - "push", - "add", - "commit", - "stash_push", - "stash_pop", - "create_branch", - "reset_hard", - "rebase", - "stash_rebase", - ): - assert hasattr(mock_gitxtend, name) + write_side_calls = { + "pull": ("/tmp/repo",), + "push": ("/tmp/repo", "origin", "main"), + "add": ("/tmp/repo", ["README.md"]), + "commit": ("/tmp/repo", "initial import"), + "stash_push": ("/tmp/repo", "before-rebase"), + "stash_pop": ("/tmp/repo",), + "create_branch": ("/tmp/repo", "feature/test"), + "reset_hard": ("/tmp/repo", "HEAD~1"), + "rebase": ("/tmp/repo", "origin/main"), + "stash_rebase": ("/tmp/repo", "origin/main"), + } + + for name, args in write_side_calls.items(): + getattr(mock_gitxtend, name).return_value = name + + assert getattr(gitxtend, name)(*args) == name + getattr(mock_gitxtend, name).assert_called_once_with(*args) From e3d1709fec7a037662ec89f80c8aff265d68a01c Mon Sep 17 00:00:00 2001 From: Shawn Hartsock Date: Sat, 6 Jun 2026 17:32:27 -0400 Subject: [PATCH 3/3] Clarify Python mock export source Co-authored-by: Codex --- python/tests/conftest.py | 20 +++----------------- python/tests/test_mock_fixture.py | 3 +++ 2 files changed, 6 insertions(+), 17 deletions(-) diff --git a/python/tests/conftest.py b/python/tests/conftest.py index 8cafc35..7e5d6cb 100644 --- a/python/tests/conftest.py +++ b/python/tests/conftest.py @@ -5,22 +5,7 @@ import pytest -PUBLIC_EXTENSION_NAMES = ( - "RepoStatus", - "ahead_behind", - "current_branch", - "fetch", - "head_sha", - "is_clean", - "is_git_repo", - "last_commit_date", - "log_subjects", - "remote_head_sha", - "remote_urls", - "repo_status", - "rev_list_count", - "status_counts", - "tracking_branch", +PLANNED_WRITE_EXTENSION_NAMES = ( "pull", "push", "add", @@ -41,8 +26,9 @@ def mock_gitxtend(monkeypatch): import gitxtend._gitxtend as extension mock_extension = MagicMock(name="gitxtend._gitxtend") + public_extension_names = (*gitxtend.__all__, *PLANNED_WRITE_EXTENSION_NAMES) - for name in PUBLIC_EXTENSION_NAMES: + for name in public_extension_names: mock = MagicMock(name=f"gitxtend._gitxtend.{name}") setattr(mock_extension, name, mock) monkeypatch.setattr(extension, name, mock, raising=False) diff --git a/python/tests/test_mock_fixture.py b/python/tests/test_mock_fixture.py index 990020d..606104c 100644 --- a/python/tests/test_mock_fixture.py +++ b/python/tests/test_mock_fixture.py @@ -1,6 +1,7 @@ from __future__ import annotations import gitxtend +from conftest import PLANNED_WRITE_EXTENSION_NAMES def test_mock_gitxtend_fixture_replaces_extension_exports(mock_gitxtend): @@ -24,6 +25,8 @@ def test_mock_gitxtend_fixture_includes_write_side_exports(mock_gitxtend): "stash_rebase": ("/tmp/repo", "origin/main"), } + assert set(write_side_calls) == set(PLANNED_WRITE_EXTENSION_NAMES) + for name, args in write_side_calls.items(): getattr(mock_gitxtend, name).return_value = name