Skip to content

Commit 93cfa1c

Browse files
alexluck-siftclaude
andcommitted
add offline mode and noop sibling plugin
Adds --sift-test-results-offline (and matching ini key) to route every create/update through the JSONL log file without contacting Sift. Skips the session-start ping, skips the import worker subprocess, and tolerates missing SIFT_API_KEY / SIFT_GRPC_URI / SIFT_REST_URI by filling placeholders. Offline + log_file=false is rejected as a usage error since the log file is the sole sink. Adds sift_client.pytest_plugin_noop as a sibling plugin: matching fixture names and method signatures, but measure* calls evaluate bounds locally via the new value_passes_bounds() helper and nothing reaches Sift. Useful when test code should keep working without any Sift configuration at all. ReportContext gains an offline=True parameter that suppresses the import worker. Test suite conftest now picks the mode based on the marker expression and presence of env vars: integration runs with a real backend stay online with the log file disabled; everything else forces offline. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 8b0f23c commit 93cfa1c

9 files changed

Lines changed: 489 additions & 24 deletions

File tree

python/docs/examples/pytest_plugin.md

Lines changed: 71 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ def sift_client() -> SiftClient:
9797
| `--sift-test-results-log-file=<path\|true\|false>` | temp file | Where the JSONL log of create/update calls goes. With a log file set, the plugin spawns an `import-test-result-log --incremental` worker that polls the file and replays entries against Sift while the run is in flight. Pass `false` to disable the file entirely; create/update calls then go straight to the API synchronously during tests. |
9898
| `--no-sift-test-results-git-metadata` | git metadata on | Skip capturing git repo/branch/commit on the report's metadata. |
9999
| `--sift-test-results-check-connection` | off | Make `report_context`, `step`, and `module_substep` no-op (yield `None`) when `client_has_connection` is `False`. Lets the same suite run locally without a Sift backend. |
100+
| `--sift-test-results-offline` | off | Route the run entirely through the JSONL log file without contacting Sift. The session-start ping is skipped, the import worker is not spawned, and the file is the sole sink. Replay it later with `import-test-result-log`. Incompatible with `--sift-test-results-log-file=false`. |
100101

101102
These can be passed permanently via `addopts`:
102103

@@ -115,6 +116,7 @@ CLI flags, when passed, override the ini values.
115116
| `sift_test_results_log_file` | string (`true` / `false` / `none` / path) | `--sift-test-results-log-file=<value>` |
116117
| `sift_test_results_git_metadata` | bool (default `true`) | `--no-sift-test-results-git-metadata` (sets to `false`) |
117118
| `sift_test_results_check_connection` | bool (default `false`) | `--sift-test-results-check-connection` |
119+
| `sift_test_results_offline` | bool (default `false`) | `--sift-test-results-offline` |
118120

119121
The default `sift_client` fixture reads its two URIs from environment first
120122
and falls back to ini keys when the env vars are unset. `SIFT_API_KEY` is
@@ -620,19 +622,21 @@ without a reachable Sift server.
620622
621623
## Running offline
622624
623-
The plugin supports two offline workflows, depending on whether you want a
624-
Sift report at all when the test environment can't reach Sift. The first
625-
turns the plugin into a no-op when the server is unreachable. The second
626-
keeps the plugin running normally and writes every create/update to a local
627-
JSONL file that you upload from a connected machine afterward.
625+
The plugin supports three offline workflows, depending on whether you want a
626+
Sift report at all when the test environment can't reach Sift, and whether
627+
you want the plugin to make any attempt to contact Sift.
628628
629629
| Pattern | Flag | Runtime behavior | Follow-up |
630630
|---|---|---|---|
631631
| Skip when offline | `--sift-test-results-check-connection` | Fixtures yield `None`, no log file, no report. Pytest still reports pass/fail. | None. |
632-
| Capture locally, upload later | `--sift-test-results-log-file=<path>` | Plugin writes every create/update to the JSONL file. | `import-test-result-log <path>` from a connected machine. |
632+
| Explicit offline | `--sift-test-results-offline` | Plugin never contacts Sift; every create/update goes to a JSONL log file. No session-start ping, no import worker. | `import-test-result-log <path>` from a connected machine. |
633+
| Capture locally, upload later | `--sift-test-results-log-file=<path>` | Plugin runs in online mode but the worker subprocess dies on connect, leaving the JSONL file on disk. | `import-test-result-log <path>` from a connected machine. |
633634
634635
Pattern 1 suits laptop dev and CI without Sift secrets. Pattern 2 suits
635-
field tests, vehicles on remote sites, and air-gapped labs.
636+
field tests, vehicles on remote sites, and air-gapped labs where you've
637+
decided up front that there is no Sift connection. Pattern 3 suits sites
638+
that *might* have a connection — let the run try, and pick up the log file
639+
if it doesn't.
636640
637641
### Pattern 1: skip when offline
638642
@@ -716,14 +720,46 @@ def client_has_connection(sift_client) -> bool:
716720
The plugin only consults this fixture when `--sift-test-results-check-connection`
717721
is set, so an unused override has no effect on a normal run.
718722
719-
### Pattern 2: capture locally, upload later
723+
### Pattern 2: explicit offline mode
724+
725+
`--sift-test-results-offline` (or `sift_test_results_offline = true` in
726+
`pyproject.toml`) tells the plugin not to contact Sift at all. Every
727+
create/update is written to a JSONL log file; the session-start ping is
728+
skipped and the import worker subprocess is not spawned. Missing
729+
`SIFT_API_KEY` / `SIFT_GRPC_URI` / `SIFT_REST_URI` env vars are tolerated
730+
in this mode since no connection is attempted.
731+
732+
```bash
733+
pytest --sift-test-results-offline --sift-test-results-log-file=./run.jsonl
734+
```
735+
736+
```ini title="pytest.ini"
737+
[pytest]
738+
addopts = --sift-test-results-offline
739+
```
740+
741+
Once you have connectivity, replay the file:
742+
743+
```bash
744+
import-test-result-log ./run.jsonl
745+
```
746+
747+
`--sift-test-results-offline` requires a log file (it's the only sink), so
748+
combining it with `sift_test_results_log_file = "false"` is a usage error.
749+
Pin the log path with `--sift-test-results-log-file=<path>` so the file is
750+
easy to find; otherwise the plugin creates a temp file and surfaces the
751+
path via a `logger.info` line at session start.
752+
753+
### Pattern 3: capture locally, upload later
720754
721755
This pattern keeps the plugin running normally even when Sift is
722756
unreachable. The plugin writes to the log file, the worker dies on connect,
723757
and the file is left on disk for you to upload later. Pin the log file path
724758
so you can find it afterward, and don't pass
725759
`--sift-test-results-check-connection`, which would suppress the logging
726-
this pattern relies on.
760+
this pattern relies on. Prefer Pattern 2 (`--sift-test-results-offline`)
761+
when you've decided in advance that the run is offline; this pattern is for
762+
the case where the run *might* connect.
727763
728764
```bash
729765
pytest --sift-test-results-log-file=./run.jsonl
@@ -752,12 +788,37 @@ The replay creates the report, steps, and measurements against Sift in one
752788
batch. See [Replaying a saved log file](#replaying-a-saved-log-file) for
753789
details on cleanup and the incremental flag.
754790
755-
!!! warning "Pin the log path for Pattern 2"
791+
!!! warning "Pin the log path for Pattern 3"
756792
Without `--sift-test-results-log-file=<path>`, the plugin writes to a
757793
`tempfile.NamedTemporaryFile` and only surfaces the path via a
758794
`logger.info` line. Always pin a known path when you intend to replay
759795
the file later.
760796
797+
## Running without the plugin: `pytest_plugin_noop`
798+
799+
When you want test code that calls `step.measure(...)`, `step.substep(...)`,
800+
etc. to keep working without any Sift configuration, wire in
801+
`sift_client.pytest_plugin_noop` instead of the real plugin. It ships
802+
matching fixture names; `measure*` calls evaluate bounds locally and return
803+
the real pass/fail boolean, but nothing is sent to Sift and no log file is
804+
written.
805+
806+
```python title="conftest.py"
807+
import os
808+
809+
pytest_plugins = [
810+
"sift_client.pytest_plugin"
811+
if os.getenv("SIFT_ENABLED")
812+
else "sift_client.pytest_plugin_noop"
813+
]
814+
```
815+
816+
The no-op plugin doesn't read `SIFT_API_KEY` / `SIFT_GRPC_URI` /
817+
`SIFT_REST_URI` and never constructs a `SiftClient`, so it's safe to load in
818+
environments that aren't allowed to talk to Sift at all. Use it instead of
819+
Pattern 1 when you'd rather have tests pass through silently than `None`-guard
820+
every `step` call site.
821+
761822
## Replaying a saved log file
762823
763824
When the worker doesn't finish cleanly the plugin will print a hint mentioning

python/lib/sift_client/_tests/conftest.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -79,5 +79,17 @@ 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 plugin mode based on whether integration tests are being run.
83+
84+
Integration runs targeting a real backend stay online with the log file
85+
disabled (writes go inline). Otherwise force offline mode so the autouse
86+
plugin fixtures construct cleanly without contacting Sift.
87+
"""
88+
is_integration_run = "integration" in (config.option.markexpr or "")
89+
have_real_backend = all(
90+
os.getenv(name) for name in ("SIFT_API_KEY", "SIFT_GRPC_URI", "SIFT_REST_URI")
91+
)
92+
if is_integration_run and have_real_backend:
93+
config.option.sift_test_results_log_file = "false"
94+
else:
95+
config.option.sift_test_results_offline = True

python/lib/sift_client/_tests/test_pytest_plugin.py

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,10 +216,12 @@ def test_defaults_when_neither_set(self, pytester: pytest.Pytester) -> None:
216216
"""
217217
from sift_client.pytest_plugin import (
218218
_check_connection_enabled,
219+
_offline_enabled,
219220
_resolve_log_file,
220221
)
221222
print("RESOLVED:", _resolve_log_file(config))
222223
print("CHECK:", _check_connection_enabled(config))
224+
print("OFFLINE:", _offline_enabled(config))
223225
print("INI_GIT:", config.getini("sift_test_results_git_metadata"))
224226
""",
225227
)
@@ -229,6 +231,111 @@ def test_defaults_when_neither_set(self, pytester: pytest.Pytester) -> None:
229231
[
230232
"RESOLVED: True",
231233
"CHECK: False",
234+
"OFFLINE: False",
232235
"INI_GIT: True",
233236
]
234237
)
238+
239+
240+
class TestOfflineMode:
241+
"""``--sift-test-results-offline`` routes the run through the JSONL log file."""
242+
243+
def test_offline_runs_without_env_vars(
244+
self, pytester: pytest.Pytester, monkeypatch: pytest.MonkeyPatch
245+
) -> None:
246+
"""Offline mode tolerates missing env vars: placeholders are filled in."""
247+
for name in ("SIFT_API_KEY", "SIFT_GRPC_URI", "SIFT_REST_URI"):
248+
monkeypatch.delenv(name, raising=False)
249+
_plugin_conftest(pytester)
250+
pytester.makepyfile(
251+
"""
252+
def test_in_bounds(step):
253+
assert step.measure(name="v", value=5.0, bounds={"min": 4.8, "max": 5.2})
254+
255+
def test_out_of_bounds(step):
256+
assert step.measure(name="v", value=10.0, bounds={"max": 5.2}) is False
257+
"""
258+
)
259+
result = pytester.runpytest("--sift-test-results-offline")
260+
result.assert_outcomes(passed=2)
261+
262+
def test_offline_via_ini(
263+
self, pytester: pytest.Pytester, monkeypatch: pytest.MonkeyPatch
264+
) -> None:
265+
"""The ``sift_test_results_offline`` ini key enables offline mode."""
266+
for name in ("SIFT_API_KEY", "SIFT_GRPC_URI", "SIFT_REST_URI"):
267+
monkeypatch.delenv(name, raising=False)
268+
_plugin_conftest(pytester)
269+
pytester.makepyprojecttoml(
270+
"""
271+
[tool.pytest.ini_options]
272+
sift_test_results_offline = true
273+
"""
274+
)
275+
pytester.makepyfile("def test_runs(step): step.measure(name='v', value=1.0)")
276+
result = pytester.runpytest()
277+
result.assert_outcomes(passed=1)
278+
279+
def test_offline_with_log_file_false_is_rejected(self, pytester: pytest.Pytester) -> None:
280+
"""Offline mode + log_file disabled is a usage error (log file is the sink)."""
281+
_plugin_conftest(pytester)
282+
pytester.makepyprojecttoml(
283+
"""
284+
[tool.pytest.ini_options]
285+
sift_test_results_offline = true
286+
sift_test_results_log_file = "false"
287+
"""
288+
)
289+
pytester.makepyfile("def test_should_not_run(): pass")
290+
result = pytester.runpytest()
291+
assert result.ret != 0
292+
combined = "\n".join(result.outlines + result.errlines)
293+
assert "incompatible" in combined, combined
294+
295+
296+
class TestNoopPlugin:
297+
"""The no-op sibling plugin keeps test code working without Sift configuration."""
298+
299+
def test_in_bounds_passes_out_of_bounds_fails(self, pytester: pytest.Pytester) -> None:
300+
"""Shim measure* evaluates bounds locally; pass/fail matches the real plugin."""
301+
pytester.makeconftest('pytest_plugins = ["sift_client.pytest_plugin_noop"]')
302+
pytester.makepyfile(
303+
"""
304+
def test_passes_in_bounds(step):
305+
assert step.measure(name="v", value=5.0, bounds={"min": 4.8, "max": 5.2})
306+
307+
def test_fails_out_of_bounds(step):
308+
assert step.measure(name="v", value=99.0, bounds={"max": 5.2}) is False
309+
310+
def test_substep_and_report_outcome(step):
311+
with step.substep(name="inner") as inner:
312+
assert inner.report_outcome(name="ok", result=True) is True
313+
314+
def test_string_bounds(step):
315+
assert step.measure(name="fw", value="1.0", bounds="1.0") is True
316+
assert step.measure(name="fw", value="1.0", bounds="2.0") is False
317+
318+
def test_measure_avg(step):
319+
assert step.measure_avg(
320+
name="bus", values=[4.97, 5.01, 5.03], bounds={"min": 4.9, "max": 5.1}
321+
) is True
322+
323+
def test_measure_all_outlier(step):
324+
assert step.measure_all(
325+
name="p", values=[10.1, 10.2, 99.9], bounds={"max": 11.0}
326+
) is False
327+
"""
328+
)
329+
result = pytester.runpytest()
330+
result.assert_outcomes(passed=6)
331+
332+
def test_noop_does_not_require_credentials(
333+
self, pytester: pytest.Pytester, monkeypatch: pytest.MonkeyPatch
334+
) -> None:
335+
"""The noop plugin never reads SIFT_* env vars; runs cleanly without them."""
336+
for name in ("SIFT_API_KEY", "SIFT_GRPC_URI", "SIFT_REST_URI"):
337+
monkeypatch.delenv(name, raising=False)
338+
pytester.makeconftest('pytest_plugins = ["sift_client.pytest_plugin_noop"]')
339+
pytester.makepyfile("def test_runs(step): step.measure(name='v', value=1.0)")
340+
result = pytester.runpytest()
341+
result.assert_outcomes(passed=1)
Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,3 @@
1-
import pytest
2-
3-
4-
def pytest_configure(config: pytest.Config) -> None:
5-
"""Configure the pytest configuration to disable the Sift test results log file."""
6-
config.option.sift_test_results_log_file = False
1+
# Plugin mode (offline vs online, log-file disable) is set by the parent
2+
# conftest based on the marker expression and env vars. No util-specific
3+
# overrides are needed.

0 commit comments

Comments
 (0)