Skip to content

Commit d8b61b6

Browse files
Python(feat): pytest summary output (#594)
1 parent 177fbd8 commit d8b61b6

14 files changed

Lines changed: 879 additions & 27 deletions

File tree

python/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ Highlights:
1616
- **Pass/fail mapping.** Every pytest outcome (pass, assertion failure, exception, skip, xfail, hard exit) maps to a `TestStatus` and propagates to parent steps and the report. `step.measure(...)` returns a pass/fail boolean without raising, so all measurements land in the report even when one fails; `step.fail_if_measurements_failed()` fails the test at the end without adding assertion noise to `error_info`.
1717
- **Assertion messages as error info.** Assertion failure messages are reported as the step's error info.
1818
- **Git metadata.** Repo, branch, and commit are captured on the report automatically.
19+
- **Terminal output.** The plugin prints a session header with the SDK version and active mode, and an end-of-run `Sift report` panel showing the test case, outcome, step and measurement breakdowns (color-coded), test system/operator, plus a link to the report (online), the saved log and upload command (offline), or a disabled note. Both suppress under `-q`. `SiftClient.app_url` exposes the web-app origin; set `sift_report_url_base` for on-prem or custom deployments. `--sift-open-report` opens the report in a browser at session end.
1920

2021
See the [Pytest Plugin guide](https://github.com/sift-stack/sift/blob/main/python/docs/guides/pytest_plugin/index.md) and the runnable quickstart example for full configuration.
2122

python/docs/guides/pytest_plugin/configuration.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,8 @@ def sift_client() -> SiftClient:
132132
| `--sift-disabled` | off | Skip Sift entirely. Nothing contacts the API and no log file is written; `step.measure(...)` still evaluates bounds and returns a real pass/fail boolean. Also honored via `SIFT_DISABLED=1`. Supersedes every other flag (disabled wins over offline). |
133133
| `--sift-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. Incompatible with `--sift-offline` since offline mode needs the log file as its sole sink. |
134134
| `--no-sift-git-metadata` | git metadata on | Skip capturing git repo/branch/commit on the report's metadata. |
135+
| `--sift-report-url-base=<origin>` | derived from REST URI | Web-app origin used to build the clickable report link in the terminal footer (e.g. `https://app.siftstack.com`). Set this for on-prem or custom deployments whose API host can't be mapped to a frontend automatically. Also honored via the `SIFT_APP_URL` environment variable. When unset, the link is derived from the REST URI for known Sift hosts. |
136+
| `--sift-open-report` | off | Open the resulting report in a browser at session end. Online mode only; a no-op when the report URL can't be resolved. Intended for local development. |
135137
136138
These can be passed permanently via `addopts`:
137139
@@ -158,6 +160,7 @@ CLI flags, when passed, override the ini values.
158160
| `sift_module_step` | bool (default `true`) | _(ini-only)_. Opens a parent step for each test module (file). |
159161
| `sift_class_step` | bool (default `true`) | _(ini-only)_. Opens a parent step for each test class, including nested classes. |
160162
| `sift_parametrize_nesting` | bool (default `true`) | _(ini-only)_. Clusters parametrized tests under shared parents (`test_x`, `axis=value`) instead of flat leaves (`test_x[value]`). |
163+
| `sift_open_report` | bool (default `false`) | `--sift-open-report` |
161164
162165
```toml title="pyproject.toml"
163166
[tool.pytest.ini_options]

python/docs/guides/pytest_plugin/running_modes.md

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,88 @@ pytest --sift-log-file=./sift-results.jsonl
2525
Pass both flags and disabled wins: it skips Sift entirely and supersedes every
2626
other setting.
2727

28+
## Terminal output
29+
30+
Each run prints a header with the SDK version and active mode, and an end-of-run
31+
`Sift report` panel summarizing the outcome. Both are suppressed under `-q`. The
32+
panel is color-coded when the terminal supports it (green pass, red
33+
failure/error, yellow skip, cyan link) and plain text otherwise (`--color=no`,
34+
captured output, CI logs).
35+
36+
The section title carries the report name (truncated if long). The `Steps` row
37+
tallies every step in the report by final status, so it counts substeps and the
38+
package/module/class/parametrize grouping steps too — its totals are expected to
39+
exceed pytest's own test count. The `Measurements` row tallies recorded
40+
measurements (`step.measure(...)`) and is omitted when there are none. The
41+
`Test case` and `System` rows echo the report's test case, test system, and
42+
operator.
43+
44+
**Online** shows the report metadata, step and measurement breakdowns, and a
45+
clickable link. The web host is derived from the REST URI for known Sift hosts;
46+
for on-prem or custom deployments set `--sift-report-url-base`
47+
(ini: `sift_report_url_base`, env: `SIFT_APP_URL`). Add `--sift-open-report` to
48+
open the report in a browser at session end.
49+
50+
```text
51+
============================= test session starts ==============================
52+
platform linux -- Python 3.11.8, pytest-8.3.2, pluggy-1.5.0
53+
Sift: sift-stack-py 0.17.0 — online mode
54+
collected 12 items
55+
56+
tests/test_battery.py ........ [ 66%]
57+
tests/test_thermal.py .... [100%]
58+
59+
================ Sift report · pytest tests/ 2026-05-27T22:44:23Z ==============
60+
Test case pytest tests/
61+
Status PASSED online · sift-stack-py 0.17.0
62+
Steps 14 passed
63+
Measurements 42 passed
64+
System ci-runner-7 · cibot
65+
Log file /tmp/sift-a1b2c3.jsonl
66+
Report https://app.siftstack.com/test-results/0193f1a2-7c44-7e5b-9b1a-2f6c0d9e84aa
67+
============================== 12 passed in 3.45s ==============================
68+
```
69+
70+
If the background uploader doesn't finish, the panel still links the report and
71+
flags that it may be incomplete:
72+
73+
```text
74+
================ Sift report · pytest tests/ 2026-05-27T22:44:23Z ==============
75+
Test case pytest tests/
76+
Status FAILED online · sift-stack-py 0.17.0
77+
Steps 11 passed · 2 failed · 1 error
78+
Measurements 40 passed · 3 failed
79+
System ci-runner-7 · cibot
80+
Log file /tmp/sift-a1b2c3.jsonl
81+
Report https://app.siftstack.com/test-results/0193f1a2-7c44-7e5b-9b1a-2f6c0d9e84aa
82+
may be incomplete — finish with: import-test-result-log /tmp/sift-a1b2c3.jsonl
83+
```
84+
85+
When the web host can't be resolved and no override is set, the `Report` row
86+
shows the report id instead of a link.
87+
88+
**Offline** shows the metadata and breakdowns, then the upload command under a
89+
small rule (the log path is part of the command):
90+
91+
```text
92+
================ Sift report · pytest tests/ 2026-05-27T22:44:23Z ==============
93+
Test case pytest tests/
94+
Status PASSED offline · not uploaded
95+
Steps 14 passed
96+
Measurements 42 passed
97+
System ci-runner-7 · cibot
98+
Log file ./run.jsonl
99+
------------------------------ to upload to Sift -------------------------------
100+
>> import-test-result-log ./run.jsonl
101+
```
102+
103+
**Disabled** notes that no report was created:
104+
105+
```text
106+
===================================== Sift =====================================
107+
Sift disabled — no test report created.
108+
```
109+
28110
## Online mode (default)
29111

30112
`report_context` resolves `client_has_connection` at session start. The default

python/lib/sift_client/_internal/grpc_transport/transport.py

Lines changed: 5 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@
88

99
from importlib.metadata import PackageNotFoundError, version
1010
from typing import TYPE_CHECKING, Any, TypedDict, cast
11-
from urllib.parse import ParseResult, urlparse
1211

1312
import grpc
1413
import grpc.aio as grpc_aio
@@ -21,6 +20,7 @@
2120
Metadata,
2221
MetadataInterceptor,
2322
)
23+
from sift_client._internal.urls import parse_host
2424

2525
if TYPE_CHECKING:
2626
from sift_client._internal.grpc_transport._async_interceptors.base import ClientAsyncInterceptor
@@ -78,7 +78,7 @@ def use_sift_channel(
7878

7979
credentials = get_ssl_credentials(cert_via_openssl)
8080
options = _compute_channel_options(config)
81-
api_uri = _clean_uri(config["uri"], use_ssl)
81+
api_uri = parse_host(config["uri"])
8282
channel = grpc.secure_channel(api_uri, credentials, options)
8383
interceptors = _compute_sift_interceptors(config, metadata)
8484
return grpc.intercept_channel(channel, *interceptors)
@@ -98,7 +98,7 @@ def use_sift_async_channel(
9898
return _use_insecure_sift_async_channel(config, metadata)
9999

100100
return grpc_aio.secure_channel(
101-
target=_clean_uri(config["uri"], use_ssl),
101+
target=parse_host(config["uri"]),
102102
credentials=get_ssl_credentials(cert_via_openssl),
103103
options=_compute_channel_options(config),
104104
interceptors=_compute_sift_async_interceptors(config, metadata),
@@ -112,7 +112,7 @@ def _use_insecure_sift_channel(
112112
FOR DEVELOPMENT PURPOSES ONLY
113113
"""
114114
options = _compute_channel_options(config)
115-
api_uri = _clean_uri(config["uri"], False)
115+
api_uri = parse_host(config["uri"])
116116
channel = grpc.insecure_channel(api_uri, options)
117117
interceptors = _compute_sift_interceptors(config, metadata)
118118
return grpc.intercept_channel(channel, *interceptors)
@@ -125,7 +125,7 @@ def _use_insecure_sift_async_channel(
125125
FOR DEVELOPMENT PURPOSES ONLY
126126
"""
127127
return grpc_aio.insecure_channel(
128-
target=_clean_uri(config["uri"], False),
128+
target=parse_host(config["uri"]),
129129
options=_compute_channel_options(config),
130130
interceptors=_compute_sift_async_interceptors(config, metadata),
131131
)
@@ -205,21 +205,6 @@ def _metadata_async_interceptor(
205205
return MetadataAsyncInterceptor(md)
206206

207207

208-
def _clean_uri(uri: str, use_ssl: bool) -> str:
209-
"""
210-
This will automatically transform the URI to an acceptable form regardless of whether or not
211-
users included the scheme in the URL or included trailing slashes.
212-
"""
213-
214-
if "http://" in uri or "https://" in uri:
215-
parsed: ParseResult = urlparse(uri)
216-
return parsed.netloc
217-
218-
full_uri = f"https://{uri}" if use_ssl else f"http://{uri}"
219-
parsed_res: ParseResult = urlparse(full_uri)
220-
return parsed_res.netloc
221-
222-
223208
def _compute_user_agent() -> str:
224209
try:
225210
return f"sift_stack_py/{version('sift_stack_py')}"

python/lib/sift_client/_internal/rest.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from typing_extensions import NotRequired
77
from urllib3.util import Retry
88

9-
from sift_client._internal.grpc_transport.transport import _clean_uri
9+
from sift_client._internal.urls import parse_host
1010

1111
_DEFAULT_REST_RETRY = Retry(total=3, status_forcelist=[500, 502, 503, 504], backoff_factor=1)
1212

@@ -33,7 +33,7 @@ class SiftRestConfig(TypedDict):
3333
def compute_uri(restconf: SiftRestConfig) -> str:
3434
uri = restconf["uri"]
3535
use_ssl = restconf.get("use_ssl", True)
36-
clean_uri = _clean_uri(uri, use_ssl)
36+
clean_uri = parse_host(uri)
3737

3838
if use_ssl:
3939
return f"https://{clean_uri}"
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
"""Helpers for turning Sift API endpoints into web-app (frontend) URLs.
2+
3+
The Sift frontend can be hosted on several domains and the backend exposes no
4+
field for its own URL, so the frontend origin is derived client-side from the
5+
API host. This table mirrors the canonical mapping used by the Grafana
6+
datasource (sift-stack/sift-grafana-datasource,
7+
``src/components/sharelink/getFrontendHostnameDefaults.ts``). Hosts outside the
8+
table (on-prem and custom deployments) require an explicit override.
9+
"""
10+
11+
from __future__ import annotations
12+
13+
from urllib.parse import urlparse
14+
15+
# API host (host[:port], no scheme) -> frontend origin (with scheme).
16+
_API_HOST_TO_FRONTEND_ORIGIN: dict[str, str] = {
17+
"api.siftstack.com": "https://app.siftstack.com",
18+
"gov.api.siftstack.com": "https://gov.siftstack.com",
19+
}
20+
21+
22+
def parse_origin(url: str) -> str:
23+
"""Normalize a URL or bare host into a ``scheme://host[:port]`` origin.
24+
25+
Bare hosts (no scheme) are assumed to be ``https``.
26+
"""
27+
candidate = url if "://" in url else f"https://{url}"
28+
parsed = urlparse(candidate)
29+
return f"{parsed.scheme}://{parsed.netloc}".rstrip("/")
30+
31+
32+
def parse_host(url: str) -> str:
33+
"""Extract ``host[:port]`` from a URL or bare host string."""
34+
candidate = url if "://" in url else f"https://{url}"
35+
return urlparse(candidate).netloc
36+
37+
38+
def frontend_origin_for_api(api_base_url: str, override: str | None = None) -> str | None:
39+
"""Return the Sift web-app origin for a given API base URL.
40+
41+
Args:
42+
api_base_url: The REST API base URL (e.g. ``https://api.siftstack.com``).
43+
override: An explicit frontend origin (host or full URL) to use instead
44+
of the derived value. Set this for on-prem or custom deployments
45+
whose API host isn't in the built-in mapping.
46+
47+
Returns:
48+
The frontend origin (e.g. ``https://app.siftstack.com``), or ``None``
49+
when no override is given and the API host isn't recognized.
50+
"""
51+
if override:
52+
return parse_origin(override)
53+
if not api_base_url:
54+
return None
55+
return _API_HOST_TO_FRONTEND_ORIGIN.get(parse_host(api_base_url))

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@
2929

3030
import pytest
3131

32-
_SIFT_ENV_VARS = ("SIFT_API_KEY", "SIFT_GRPC_URI", "SIFT_REST_URI", "SIFT_DISABLED")
32+
_SIFT_ENV_VARS = ("SIFT_API_KEY", "SIFT_GRPC_URI", "SIFT_REST_URI", "SIFT_DISABLED", "SIFT_APP_URL")
3333

3434

3535
@pytest.fixture

0 commit comments

Comments
 (0)