Skip to content

Commit 85b2d4b

Browse files
Python(feat): pytest graceful handling missing connection (#569)
1 parent 0bb2d5a commit 85b2d4b

21 files changed

Lines changed: 1204 additions & 352 deletions

File tree

python/docs/examples/pytest_plugin.md

Lines changed: 104 additions & 126 deletions
Large diffs are not rendered by default.

python/lib/sift_client/_internal/low_level_wrappers/test_results.py

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import logging
44
import uuid
55
from pathlib import Path
6-
from typing import TYPE_CHECKING, Any, cast
6+
from typing import TYPE_CHECKING, Any, TypeVar, cast
77

88
from google.protobuf import json_format
99
from sift.test_reports.v1.test_reports_pb2 import (
@@ -68,6 +68,9 @@
6868
logger = logging.getLogger(__name__)
6969

7070

71+
_EntityT = TypeVar("_EntityT", TestReport, TestStep, TestMeasurement)
72+
73+
7174
class TestResultsLowLevelClient(LowLevelClientBase, WithGrpcClient):
7275
"""Low-level client for the TestResultsAPI.
7376
@@ -82,6 +85,16 @@ def __init__(self, grpc_client: GrpcClient):
8285
"""
8386
super().__init__(grpc_client)
8487

88+
@staticmethod
89+
def _mark_simulated(instance: _EntityT) -> _EntityT:
90+
"""Stamp an entity as having been produced by the simulate path.
91+
92+
Mirrors the ``__dict__`` write used by ``BaseType._apply_client_to_instance``
93+
to bypass pydantic's frozen-model guard.
94+
"""
95+
instance.__dict__["_simulated"] = True
96+
return instance
97+
8598
@staticmethod
8699
def simulate_create_test_report_response(
87100
request: CreateTestReportRequest,
@@ -387,7 +400,7 @@ async def create_test_report(
387400
request,
388401
response_id=simulated_proto.test_report_id,
389402
)
390-
return TestReport._from_proto(simulated_proto)
403+
return self._mark_simulated(TestReport._from_proto(simulated_proto))
391404

392405
response = await self._grpc_client.get_stub(TestReportServiceStub).CreateTestReport(request)
393406
grpc_test_report = cast("CreateTestReportResponse", response).test_report
@@ -505,7 +518,9 @@ async def update_test_report(
505518
if log_file is not None or simulate:
506519
if log_file is not None:
507520
log_request_to_file(log_file, "UpdateTestReport", request)
508-
return self.simulate_update_test_report_response(request, existing=existing)
521+
return self._mark_simulated(
522+
self.simulate_update_test_report_response(request, existing=existing)
523+
)
509524

510525
response = await self._grpc_client.get_stub(TestReportServiceStub).UpdateTestReport(request)
511526
grpc_test_report = cast("UpdateTestReportResponse", response).test_report
@@ -560,7 +575,7 @@ async def create_test_step(
560575
request,
561576
response_id=simulated_proto.test_step_id,
562577
)
563-
return TestStep._from_proto(simulated_proto)
578+
return self._mark_simulated(TestStep._from_proto(simulated_proto))
564579

565580
response = await self._grpc_client.get_stub(TestReportServiceStub).CreateTestStep(request)
566581
grpc_test_step = cast("CreateTestStepResponse", response).test_step
@@ -661,7 +676,9 @@ async def update_test_step(
661676
if log_file is not None or simulate:
662677
if log_file is not None:
663678
log_request_to_file(log_file, "UpdateTestStep", request)
664-
return self.simulate_update_test_step_response(request, existing=existing)
679+
return self._mark_simulated(
680+
self.simulate_update_test_step_response(request, existing=existing)
681+
)
665682

666683
response = await self._grpc_client.get_stub(TestReportServiceStub).UpdateTestStep(request)
667684
grpc_test_step = cast("UpdateTestStepResponse", response).test_step
@@ -716,7 +733,7 @@ async def create_test_measurement(
716733
request,
717734
response_id=simulated_proto.measurement_id,
718735
)
719-
return TestMeasurement._from_proto(simulated_proto)
736+
return self._mark_simulated(TestMeasurement._from_proto(simulated_proto))
720737

721738
response = await self._grpc_client.get_stub(TestReportServiceStub).CreateTestMeasurement(
722739
request
@@ -861,7 +878,9 @@ async def update_test_measurement(
861878
if log_file is not None or simulate:
862879
if log_file is not None:
863880
log_request_to_file(log_file, "UpdateTestMeasurement", request)
864-
return self.simulate_update_test_measurement_response(request, existing=existing)
881+
return self._mark_simulated(
882+
self.simulate_update_test_measurement_response(request, existing=existing)
883+
)
865884

866885
response = await self._grpc_client.get_stub(TestReportServiceStub).UpdateTestMeasurement(
867886
request

python/lib/sift_client/_tests/conftest.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -79,5 +79,13 @@ def ci_pytest_tag(sift_client):
7979

8080

8181
def pytest_configure(config: pytest.Config) -> None:
82-
"""Enable the Sift connection-check mode for the fixtures used in this test suite since we run w/ mock client in non-integration tests."""
83-
config.option.sift_test_results_check_connection = True
82+
"""Pick a Sift plugin mode based on whether integration tests are running.
83+
84+
Integration runs (``-m integration``) stay online with the default
85+
log-file pipeline enabled so CI exercises the JSONL write + import
86+
worker replay path that production users hit. Every other run defaults
87+
to ``--sift-disabled`` so unit tests don't need credentials.
88+
"""
89+
is_integration_run = "integration" in (config.option.markexpr or "")
90+
if not is_integration_run:
91+
config.option.sift_disabled = True

python/lib/sift_client/_tests/pytest_plugin/conftest.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,15 @@
2525

2626
import pytest
2727

28+
_SIFT_ENV_VARS = ("SIFT_API_KEY", "SIFT_GRPC_URI", "SIFT_REST_URI", "SIFT_DISABLED")
29+
30+
31+
@pytest.fixture
32+
def clear_sift_env(monkeypatch: pytest.MonkeyPatch) -> None:
33+
"""Unset all ``SIFT_*`` environment variables for the duration of the test."""
34+
for name in _SIFT_ENV_VARS:
35+
monkeypatch.delenv(name, raising=False)
36+
2837

2938
@pytest.fixture
3039
def write_plugin_conftest(pytester: pytest.Pytester) -> Callable[[], None]:

python/lib/sift_client/_tests/pytest_plugin/test_configuration.py

Lines changed: 72 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ def test_ini_log_file_none(
3434
pytester.makepyprojecttoml(
3535
"""
3636
[tool.pytest.ini_options]
37-
sift_test_results_log_file = "none"
37+
sift_log_file = "none"
3838
"""
3939
)
4040
pytester.makepyfile("def test_noop(): pass")
@@ -46,7 +46,7 @@ def test_python_false_disables_log_file(
4646
pytester: pytest.Pytester,
4747
write_probe_conftest: Callable[[str], None],
4848
) -> None:
49-
"""`config.option.sift_test_results_log_file = False` disables logging.
49+
"""`config.option.sift_log_file = False` disables logging.
5050
5151
Conftests use this pattern (see lib/sift_client/_tests/util/conftest.py)
5252
to opt their subtree out of log-file mode. Regression test for the
@@ -55,7 +55,7 @@ def test_python_false_disables_log_file(
5555
"""
5656
write_probe_conftest(
5757
"""
58-
config.option.sift_test_results_log_file = False
58+
config.option.sift_log_file = False
5959
from sift_client.pytest_plugin import _resolve_log_file
6060
print("RESOLVED:", _resolve_log_file(config))
6161
""",
@@ -80,33 +80,54 @@ def test_ini_log_file_path(
8080
pytester.makepyprojecttoml(
8181
f"""
8282
[tool.pytest.ini_options]
83-
sift_test_results_log_file = "{log_path}"
83+
sift_log_file = "{log_path}"
8484
"""
8585
)
8686
pytester.makepyfile("def test_noop(): pass")
8787
result = pytester.runpytest_subprocess("-s", "--co")
8888
result.stdout.fnmatch_lines([f"RESOLVED: {log_path}"])
8989

90-
def test_ini_check_connection_true(
90+
def test_ini_offline_true(
9191
self,
9292
pytester: pytest.Pytester,
9393
write_probe_conftest: Callable[[str], None],
9494
) -> None:
9595
write_probe_conftest(
9696
"""
97-
from sift_client.pytest_plugin import _check_connection_enabled
98-
print("CHECK:", _check_connection_enabled(config))
97+
from sift_client.pytest_plugin import _is_offline
98+
print("OFFLINE:", _is_offline(config))
9999
""",
100100
)
101101
pytester.makepyprojecttoml(
102102
"""
103103
[tool.pytest.ini_options]
104-
sift_test_results_check_connection = true
104+
sift_offline = true
105105
"""
106106
)
107107
pytester.makepyfile("def test_noop(): pass")
108108
result = pytester.runpytest_subprocess("-s", "--co")
109-
result.stdout.fnmatch_lines(["CHECK: True"])
109+
result.stdout.fnmatch_lines(["OFFLINE: True"])
110+
111+
def test_ini_disabled_true(
112+
self,
113+
pytester: pytest.Pytester,
114+
write_probe_conftest: Callable[[str], None],
115+
) -> None:
116+
write_probe_conftest(
117+
"""
118+
from sift_client.pytest_plugin import _is_disabled
119+
print("DISABLED:", _is_disabled(config))
120+
""",
121+
)
122+
pytester.makepyprojecttoml(
123+
"""
124+
[tool.pytest.ini_options]
125+
sift_disabled = true
126+
"""
127+
)
128+
pytester.makepyfile("def test_noop(): pass")
129+
result = pytester.runpytest_subprocess("-s", "--co")
130+
result.stdout.fnmatch_lines(["DISABLED: True"])
110131

111132
def test_ini_git_metadata_false(
112133
self,
@@ -115,13 +136,13 @@ def test_ini_git_metadata_false(
115136
) -> None:
116137
write_probe_conftest(
117138
"""
118-
print("INI_GIT:", config.getini("sift_test_results_git_metadata"))
139+
print("INI_GIT:", config.getini("sift_git_metadata"))
119140
""",
120141
)
121142
pytester.makepyprojecttoml(
122143
"""
123144
[tool.pytest.ini_options]
124-
sift_test_results_git_metadata = false
145+
sift_git_metadata = false
125146
"""
126147
)
127148
pytester.makepyfile("def test_noop(): pass")
@@ -145,49 +166,63 @@ def test_cli_overrides_ini(
145166
pytester.makepyprojecttoml(
146167
"""
147168
[tool.pytest.ini_options]
148-
sift_test_results_log_file = "none"
169+
sift_log_file = "none"
149170
"""
150171
)
151172
pytester.makepyfile("def test_noop(): pass")
152-
result = pytester.runpytest_subprocess(
153-
"-s", "--co", f"--sift-test-results-log-file={cli_path}"
154-
)
173+
result = pytester.runpytest_subprocess("-s", "--co", f"--sift-log-file={cli_path}")
155174
result.stdout.fnmatch_lines([f"RESOLVED: {cli_path}"])
156175

157-
def test_cli_check_connection_flag(
176+
def test_cli_offline_flag(
177+
self,
178+
pytester: pytest.Pytester,
179+
write_probe_conftest: Callable[[str], None],
180+
) -> None:
181+
"""The ``--sift-offline`` CLI flag flips the resolver to True."""
182+
write_probe_conftest(
183+
"""
184+
from sift_client.pytest_plugin import _is_offline
185+
print("OFFLINE:", _is_offline(config))
186+
""",
187+
)
188+
pytester.makepyfile("def test_noop(): pass")
189+
result = pytester.runpytest_subprocess("-s", "--co", "--sift-offline")
190+
result.stdout.fnmatch_lines(["OFFLINE: True"])
191+
192+
def test_cli_disabled_flag(
158193
self,
159194
pytester: pytest.Pytester,
160195
write_probe_conftest: Callable[[str], None],
161196
) -> None:
162-
"""The ``--sift-test-results-check-connection`` CLI flag flips the resolver to True."""
197+
"""The ``--sift-disabled`` CLI flag flips the resolver to True."""
163198
write_probe_conftest(
164199
"""
165-
from sift_client.pytest_plugin import _check_connection_enabled
166-
print("CHECK:", _check_connection_enabled(config))
200+
from sift_client.pytest_plugin import _is_disabled
201+
print("DISABLED:", _is_disabled(config))
167202
""",
168203
)
169204
pytester.makepyfile("def test_noop(): pass")
170-
result = pytester.runpytest_subprocess("-s", "--co", "--sift-test-results-check-connection")
171-
result.stdout.fnmatch_lines(["CHECK: True"])
205+
result = pytester.runpytest_subprocess("-s", "--co", "--sift-disabled")
206+
result.stdout.fnmatch_lines(["DISABLED: True"])
172207

173208
def test_cli_no_git_metadata_flag(
174209
self,
175210
pytester: pytest.Pytester,
176211
write_probe_conftest: Callable[[str], None],
177212
) -> None:
178-
"""The ``--no-sift-test-results-git-metadata`` CLI flag flips git_metadata to False.
213+
"""The ``--no-sift-git-metadata`` CLI flag flips git_metadata to False.
179214
180215
Guards the negation flag's ``dest`` binding: the flag name doesn't match
181216
the ini key, so a broken ``dest`` would silently fall back to the ini
182217
default and pass every other test in this file.
183218
"""
184219
write_probe_conftest(
185220
"""
186-
print("CLI_GIT:", config.getoption("sift_test_results_git_metadata"))
221+
print("CLI_GIT:", config.getoption("sift_git_metadata"))
187222
""",
188223
)
189224
pytester.makepyfile("def test_noop(): pass")
190-
result = pytester.runpytest_subprocess("-s", "--co", "--no-sift-test-results-git-metadata")
225+
result = pytester.runpytest_subprocess("-s", "--co", "--no-sift-git-metadata")
191226
result.stdout.fnmatch_lines(["CLI_GIT: False"])
192227

193228
def test_defaults_when_neither_set(
@@ -198,20 +233,23 @@ def test_defaults_when_neither_set(
198233
write_probe_conftest(
199234
"""
200235
from sift_client.pytest_plugin import (
201-
_check_connection_enabled,
236+
_is_disabled,
237+
_is_offline,
202238
_resolve_log_file,
203239
)
204240
print("RESOLVED:", _resolve_log_file(config))
205-
print("CHECK:", _check_connection_enabled(config))
206-
print("INI_GIT:", config.getini("sift_test_results_git_metadata"))
241+
print("OFFLINE:", _is_offline(config))
242+
print("DISABLED:", _is_disabled(config))
243+
print("INI_GIT:", config.getini("sift_git_metadata"))
207244
""",
208245
)
209246
pytester.makepyfile("def test_noop(): pass")
210247
result = pytester.runpytest_subprocess("-s", "--co")
211248
result.stdout.fnmatch_lines(
212249
[
213250
"RESOLVED: True",
214-
"CHECK: False",
251+
"OFFLINE: False",
252+
"DISABLED: False",
215253
"INI_GIT: True",
216254
]
217255
)
@@ -238,7 +276,7 @@ def report_context():
238276

239277

240278
class TestAutouseGate:
241-
"""`sift_include` / `sift_exclude` markers and the `sift_test_results_autouse` ini gate."""
279+
"""`sift_include` / `sift_exclude` markers and the `sift_autouse` ini gate."""
242280

243281
def test_default_ini_true_activates(self, pytester: pytest.Pytester) -> None:
244282
"""Plugin default (ini absent) keeps the autouse fixtures active."""
@@ -253,12 +291,12 @@ def test_inner(step):
253291
result.assert_outcomes(passed=1)
254292

255293
def test_default_ini_false_skips(self, pytester: pytest.Pytester) -> None:
256-
"""`sift_test_results_autouse = false` makes the autouse fixtures no-op by default."""
294+
"""`sift_autouse = false` makes the autouse fixtures no-op by default."""
257295
pytester.makeconftest(_GATE_INNER_CONFTEST)
258296
pytester.makepyprojecttoml(
259297
"""
260298
[tool.pytest.ini_options]
261-
sift_test_results_autouse = false
299+
sift_autouse = false
262300
"""
263301
)
264302
pytester.makepyfile(
@@ -276,7 +314,7 @@ def test_sift_include_marker_forces_on(self, pytester: pytest.Pytester) -> None:
276314
pytester.makepyprojecttoml(
277315
"""
278316
[tool.pytest.ini_options]
279-
sift_test_results_autouse = false
317+
sift_autouse = false
280318
"""
281319
)
282320
pytester.makepyfile(
@@ -328,7 +366,7 @@ def test_module_pytestmark_inherits(self, pytester: pytest.Pytester) -> None:
328366
pytester.makepyprojecttoml(
329367
"""
330368
[tool.pytest.ini_options]
331-
sift_test_results_autouse = false
369+
sift_autouse = false
332370
"""
333371
)
334372
pytester.makepyfile(
@@ -359,7 +397,7 @@ def test_bulk_apply_via_conftest_hook(self, pytester: pytest.Pytester) -> None:
359397
pytester.makepyprojecttoml(
360398
"""
361399
[tool.pytest.ini_options]
362-
sift_test_results_autouse = false
400+
sift_autouse = false
363401
"""
364402
)
365403
included = pytester.mkdir("included_subtree")

0 commit comments

Comments
 (0)