diff --git a/.githooks/pre-push b/.githooks/pre-push index 4f83ec2..e18b142 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" - .venv/bin/maturin develop --release >/dev/null - .venv/bin/python python/tests/test_e2e.py + echo "[pre-push] maturin develop + pytest coverage" + .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 --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)" fi diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2c86634..bc9137f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -54,5 +54,5 @@ jobs: run: | python -m venv .venv .venv/bin/pip install -q maturin - .venv/bin/maturin develop --release - .venv/bin/python python/tests/test_e2e.py + .venv/bin/maturin develop --release --extras dev + .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..7e5d6cb --- /dev/null +++ b/python/tests/conftest.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +from unittest.mock import MagicMock + +import pytest + + +PLANNED_WRITE_EXTENSION_NAMES = ( + "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") + public_extension_names = (*gitxtend.__all__, *PLANNED_WRITE_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) + 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..606104c --- /dev/null +++ b/python/tests/test_mock_fixture.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +import gitxtend +from conftest import PLANNED_WRITE_EXTENSION_NAMES + + +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): + 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"), + } + + 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 + + assert getattr(gitxtend, name)(*args) == name + getattr(mock_gitxtend, name).assert_called_once_with(*args)