Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .githooks/pre-push-python/extras.sh
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
# ensure generated pyproject.toml extras are up-to-date

# Clear git env vars set by the parent hook so git commands resolve the work tree normally
unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE GIT_PREFIX

# Store the root directory of the repository
REPO_ROOT="$(git rev-parse --show-toplevel)"
PYTHON_DIR="$REPO_ROOT/python"
Expand Down
3 changes: 3 additions & 0 deletions .githooks/pre-push-python/fmt-lint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

set -e

# Clear git env vars set by the parent hook so git commands resolve the work tree normally
unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE GIT_PREFIX

# Store the root directory of the repository
REPO_ROOT="$(git rev-parse --show-toplevel)"
PYTHON_DIR="$REPO_ROOT/python"
Expand Down
3 changes: 3 additions & 0 deletions .githooks/pre-push-python/stubs.sh
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
# ensure generated python stubs are up-to-date, from sync clients

# Clear git env vars set by the parent hook so git commands resolve the work tree normally
unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE GIT_PREFIX

# Store the root directory of the repository
REPO_ROOT="$(git rev-parse --show-toplevel)"
PYTHON_DIR="$REPO_ROOT/python"
Expand Down
375 changes: 237 additions & 138 deletions python/docs/examples/pytest_plugin.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import logging
import uuid
from pathlib import Path
from typing import TYPE_CHECKING, Any, cast
from typing import TYPE_CHECKING, Any, TypeVar, cast

from google.protobuf import json_format
from sift.test_reports.v1.test_reports_pb2 import (
Expand Down Expand Up @@ -68,6 +68,9 @@
logger = logging.getLogger(__name__)


_EntityT = TypeVar("_EntityT", TestReport, TestStep, TestMeasurement)


class TestResultsLowLevelClient(LowLevelClientBase, WithGrpcClient):
"""Low-level client for the TestResultsAPI.

Expand All @@ -82,6 +85,16 @@ def __init__(self, grpc_client: GrpcClient):
"""
super().__init__(grpc_client)

@staticmethod
def _mark_simulated(instance: _EntityT) -> _EntityT:
"""Stamp an entity as having been produced by the simulate path.

Mirrors the ``__dict__`` write used by ``BaseType._apply_client_to_instance``
to bypass pydantic's frozen-model guard.
"""
instance.__dict__["_simulated"] = True
return instance

@staticmethod
def simulate_create_test_report_response(
request: CreateTestReportRequest,
Expand Down Expand Up @@ -387,7 +400,7 @@ async def create_test_report(
request,
response_id=simulated_proto.test_report_id,
)
return TestReport._from_proto(simulated_proto)
return self._mark_simulated(TestReport._from_proto(simulated_proto))

response = await self._grpc_client.get_stub(TestReportServiceStub).CreateTestReport(request)
grpc_test_report = cast("CreateTestReportResponse", response).test_report
Expand Down Expand Up @@ -505,7 +518,9 @@ async def update_test_report(
if log_file is not None or simulate:
if log_file is not None:
log_request_to_file(log_file, "UpdateTestReport", request)
return self.simulate_update_test_report_response(request, existing=existing)
return self._mark_simulated(
self.simulate_update_test_report_response(request, existing=existing)
)

response = await self._grpc_client.get_stub(TestReportServiceStub).UpdateTestReport(request)
grpc_test_report = cast("UpdateTestReportResponse", response).test_report
Expand Down Expand Up @@ -560,7 +575,7 @@ async def create_test_step(
request,
response_id=simulated_proto.test_step_id,
)
return TestStep._from_proto(simulated_proto)
return self._mark_simulated(TestStep._from_proto(simulated_proto))

response = await self._grpc_client.get_stub(TestReportServiceStub).CreateTestStep(request)
grpc_test_step = cast("CreateTestStepResponse", response).test_step
Expand Down Expand Up @@ -661,7 +676,9 @@ async def update_test_step(
if log_file is not None or simulate:
if log_file is not None:
log_request_to_file(log_file, "UpdateTestStep", request)
return self.simulate_update_test_step_response(request, existing=existing)
return self._mark_simulated(
self.simulate_update_test_step_response(request, existing=existing)
)

response = await self._grpc_client.get_stub(TestReportServiceStub).UpdateTestStep(request)
grpc_test_step = cast("UpdateTestStepResponse", response).test_step
Expand Down Expand Up @@ -716,7 +733,7 @@ async def create_test_measurement(
request,
response_id=simulated_proto.measurement_id,
)
return TestMeasurement._from_proto(simulated_proto)
return self._mark_simulated(TestMeasurement._from_proto(simulated_proto))

response = await self._grpc_client.get_stub(TestReportServiceStub).CreateTestMeasurement(
request
Expand Down Expand Up @@ -861,7 +878,9 @@ async def update_test_measurement(
if log_file is not None or simulate:
if log_file is not None:
log_request_to_file(log_file, "UpdateTestMeasurement", request)
return self.simulate_update_test_measurement_response(request, existing=existing)
return self._mark_simulated(
self.simulate_update_test_measurement_response(request, existing=existing)
)

response = await self._grpc_client.get_stub(TestReportServiceStub).UpdateTestMeasurement(
request
Expand Down
16 changes: 10 additions & 6 deletions python/lib/sift_client/_tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,10 +78,14 @@ def ci_pytest_tag(sift_client):
return tag


# Import the Sift test results fixtures the way we recommend to users.
from sift_client.util.test_results import * # noqa: F403


def pytest_configure(config: pytest.Config) -> None:
"""Enable the Sift connection-check mode for the fixtures used in this test suite since we run w/ mock client in non-integration tests."""
config.option.sift_test_results_check_connection = True
"""Pick a Sift plugin mode based on whether integration tests are running.

Integration runs (``-m integration``) stay online with the default
log-file pipeline enabled so CI exercises the JSONL write + import
worker replay path that production users hit. Every other run defaults
to ``--sift-disabled`` so unit tests don't need credentials.
"""
is_integration_run = "integration" in (config.option.markexpr or "")
if not is_integration_run:
config.option.sift_disabled = True
Empty file.
63 changes: 63 additions & 0 deletions python/lib/sift_client/_tests/pytest_plugin/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
"""Shared helpers for the pytest-plugin test suite.

The tests in this directory drive inner pytester sessions to exercise the
plugin's behavior in isolation. The fixtures below produce the boilerplate
conftests those inner sessions need:

- ``write_plugin_conftest``: minimal conftest that loads the plugin
- ``write_probe_conftest``: conftest that loads the plugin and runs a probe
block inside ``pytest_configure``, useful for inspecting internal state
without running tests against a real backend

Every test in this suite invokes the inner session via
``pytester.runpytest_subprocess(...)`` rather than ``pytester.runpytest(...)``.
``runpytest`` runs the inner pytest in-process, which re-imports the Sift
plugin on each test; the plugin transitively imports numpy, whose C
extensions refuse to initialize twice in one process and raise
``cannot load module more than once per process``. Spawning a subprocess
gives each inner session a fresh interpreter and sidesteps that guard.
"""

from __future__ import annotations

import textwrap
from typing import Callable

import pytest

_SIFT_ENV_VARS = ("SIFT_API_KEY", "SIFT_GRPC_URI", "SIFT_REST_URI", "SIFT_DISABLED")


@pytest.fixture
def clear_sift_env(monkeypatch: pytest.MonkeyPatch) -> None:
"""Unset all ``SIFT_*`` environment variables for the duration of the test."""
for name in _SIFT_ENV_VARS:
monkeypatch.delenv(name, raising=False)


@pytest.fixture
def write_plugin_conftest(pytester: pytest.Pytester) -> Callable[[], None]:
"""Return a callable that writes a minimal conftest loading the plugin."""

def _write() -> None:
pytester.makeconftest('pytest_plugins = ["sift_client.pytest_plugin"]')

return _write


@pytest.fixture
def write_probe_conftest(pytester: pytest.Pytester) -> Callable[[str], None]:
"""Return a callable that writes a conftest running ``probe_body`` in ``pytest_configure``.

``probe_body`` is python source that runs at config time with ``config``
in scope; use ``print(...)`` calls and capture them with
``result.stdout.fnmatch_lines``.
"""

def _write(probe_body: str) -> None:
pytester.makeconftest(
'pytest_plugins = ["sift_client.pytest_plugin"]\n\n'
"def pytest_configure(config):\n" + textwrap.indent(textwrap.dedent(probe_body), " ")
)

return _write
Loading