sift-stack-py 0.17.0.dev0__py3-none-any.whl → 0.17.0.dev1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- sift_client/_internal/grpc_transport/transport.py +5 -20
- sift_client/_internal/rest.py +2 -2
- sift_client/_internal/urls.py +55 -0
- sift_client/client.py +27 -0
- sift_client/pytest_plugin.py +340 -0
- sift_client/sift_types/test_report.py +32 -0
- sift_client/transport/base_connection.py +6 -0
- sift_client/util/test_results/context_manager.py +56 -4
- {sift_stack_py-0.17.0.dev0.dist-info → sift_stack_py-0.17.0.dev1.dist-info}/METADATA +1 -1
- {sift_stack_py-0.17.0.dev0.dist-info → sift_stack_py-0.17.0.dev1.dist-info}/RECORD +14 -13
- {sift_stack_py-0.17.0.dev0.dist-info → sift_stack_py-0.17.0.dev1.dist-info}/LICENSE +0 -0
- {sift_stack_py-0.17.0.dev0.dist-info → sift_stack_py-0.17.0.dev1.dist-info}/WHEEL +0 -0
- {sift_stack_py-0.17.0.dev0.dist-info → sift_stack_py-0.17.0.dev1.dist-info}/entry_points.txt +0 -0
- {sift_stack_py-0.17.0.dev0.dist-info → sift_stack_py-0.17.0.dev1.dist-info}/top_level.txt +0 -0
|
@@ -8,7 +8,6 @@ from __future__ import annotations
|
|
|
8
8
|
|
|
9
9
|
from importlib.metadata import PackageNotFoundError, version
|
|
10
10
|
from typing import TYPE_CHECKING, Any, TypedDict, cast
|
|
11
|
-
from urllib.parse import ParseResult, urlparse
|
|
12
11
|
|
|
13
12
|
import grpc
|
|
14
13
|
import grpc.aio as grpc_aio
|
|
@@ -21,6 +20,7 @@ from sift_client._internal.grpc_transport._interceptors.metadata import (
|
|
|
21
20
|
Metadata,
|
|
22
21
|
MetadataInterceptor,
|
|
23
22
|
)
|
|
23
|
+
from sift_client._internal.urls import parse_host
|
|
24
24
|
|
|
25
25
|
if TYPE_CHECKING:
|
|
26
26
|
from sift_client._internal.grpc_transport._async_interceptors.base import ClientAsyncInterceptor
|
|
@@ -78,7 +78,7 @@ def use_sift_channel(
|
|
|
78
78
|
|
|
79
79
|
credentials = get_ssl_credentials(cert_via_openssl)
|
|
80
80
|
options = _compute_channel_options(config)
|
|
81
|
-
api_uri =
|
|
81
|
+
api_uri = parse_host(config["uri"])
|
|
82
82
|
channel = grpc.secure_channel(api_uri, credentials, options)
|
|
83
83
|
interceptors = _compute_sift_interceptors(config, metadata)
|
|
84
84
|
return grpc.intercept_channel(channel, *interceptors)
|
|
@@ -98,7 +98,7 @@ def use_sift_async_channel(
|
|
|
98
98
|
return _use_insecure_sift_async_channel(config, metadata)
|
|
99
99
|
|
|
100
100
|
return grpc_aio.secure_channel(
|
|
101
|
-
target=
|
|
101
|
+
target=parse_host(config["uri"]),
|
|
102
102
|
credentials=get_ssl_credentials(cert_via_openssl),
|
|
103
103
|
options=_compute_channel_options(config),
|
|
104
104
|
interceptors=_compute_sift_async_interceptors(config, metadata),
|
|
@@ -112,7 +112,7 @@ def _use_insecure_sift_channel(
|
|
|
112
112
|
FOR DEVELOPMENT PURPOSES ONLY
|
|
113
113
|
"""
|
|
114
114
|
options = _compute_channel_options(config)
|
|
115
|
-
api_uri =
|
|
115
|
+
api_uri = parse_host(config["uri"])
|
|
116
116
|
channel = grpc.insecure_channel(api_uri, options)
|
|
117
117
|
interceptors = _compute_sift_interceptors(config, metadata)
|
|
118
118
|
return grpc.intercept_channel(channel, *interceptors)
|
|
@@ -125,7 +125,7 @@ def _use_insecure_sift_async_channel(
|
|
|
125
125
|
FOR DEVELOPMENT PURPOSES ONLY
|
|
126
126
|
"""
|
|
127
127
|
return grpc_aio.insecure_channel(
|
|
128
|
-
target=
|
|
128
|
+
target=parse_host(config["uri"]),
|
|
129
129
|
options=_compute_channel_options(config),
|
|
130
130
|
interceptors=_compute_sift_async_interceptors(config, metadata),
|
|
131
131
|
)
|
|
@@ -205,21 +205,6 @@ def _metadata_async_interceptor(
|
|
|
205
205
|
return MetadataAsyncInterceptor(md)
|
|
206
206
|
|
|
207
207
|
|
|
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
|
-
|
|
223
208
|
def _compute_user_agent() -> str:
|
|
224
209
|
try:
|
|
225
210
|
return f"sift_stack_py/{version('sift_stack_py')}"
|
sift_client/_internal/rest.py
CHANGED
|
@@ -6,7 +6,7 @@ from requests.adapters import HTTPAdapter
|
|
|
6
6
|
from typing_extensions import NotRequired
|
|
7
7
|
from urllib3.util import Retry
|
|
8
8
|
|
|
9
|
-
from sift_client._internal.
|
|
9
|
+
from sift_client._internal.urls import parse_host
|
|
10
10
|
|
|
11
11
|
_DEFAULT_REST_RETRY = Retry(total=3, status_forcelist=[500, 502, 503, 504], backoff_factor=1)
|
|
12
12
|
|
|
@@ -33,7 +33,7 @@ class SiftRestConfig(TypedDict):
|
|
|
33
33
|
def compute_uri(restconf: SiftRestConfig) -> str:
|
|
34
34
|
uri = restconf["uri"]
|
|
35
35
|
use_ssl = restconf.get("use_ssl", True)
|
|
36
|
-
clean_uri =
|
|
36
|
+
clean_uri = parse_host(uri)
|
|
37
37
|
|
|
38
38
|
if use_ssl:
|
|
39
39
|
return f"https://{clean_uri}"
|
|
@@ -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))
|
sift_client/client.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
from sift_client._internal.urls import frontend_origin_for_api
|
|
3
4
|
from sift_client.resources import (
|
|
4
5
|
AssetsAPI,
|
|
5
6
|
AssetsAPIAsync,
|
|
@@ -124,6 +125,7 @@ class SiftClient(
|
|
|
124
125
|
grpc_url: str | None = None,
|
|
125
126
|
rest_url: str | None = None,
|
|
126
127
|
connection_config: SiftConnectionConfig | None = None,
|
|
128
|
+
app_url: str | None = None,
|
|
127
129
|
):
|
|
128
130
|
"""Initialize the SiftClient with specific connection parameters or a connection_config.
|
|
129
131
|
|
|
@@ -132,6 +134,10 @@ class SiftClient(
|
|
|
132
134
|
grpc_url: The Sift gRPC API URL.
|
|
133
135
|
rest_url: The Sift REST API URL.
|
|
134
136
|
connection_config: A SiftConnectionConfig object to configure the connection behavior of the SiftClient.
|
|
137
|
+
app_url: The Sift web-app origin (e.g. ``https://app.siftstack.com``).
|
|
138
|
+
Set this for on-prem or custom deployments whose API host can't be
|
|
139
|
+
mapped to a frontend automatically; see the ``app_url`` property.
|
|
140
|
+
A value here takes precedence over ``connection_config.app_url``.
|
|
135
141
|
"""
|
|
136
142
|
if not (api_key and grpc_url and rest_url) and not connection_config:
|
|
137
143
|
raise ValueError(
|
|
@@ -152,6 +158,12 @@ class SiftClient(
|
|
|
152
158
|
WithGrpcClient.__init__(self, grpc_client=grpc_client)
|
|
153
159
|
WithRestClient.__init__(self, rest_client=rest_client)
|
|
154
160
|
|
|
161
|
+
# Explicit web-app origin override; falls back to the connection config's
|
|
162
|
+
# value, then to host-based derivation in the ``app_url`` property.
|
|
163
|
+
self._app_url: str | None = app_url or (
|
|
164
|
+
connection_config.app_url if connection_config else None
|
|
165
|
+
)
|
|
166
|
+
|
|
155
167
|
# When set, test-results writes return synthesized responses without
|
|
156
168
|
# contacting Sift. Read by `TestResultsAPIAsync._simulate`. Used by the
|
|
157
169
|
# pytest plugin's ``--sift-disabled`` mode.
|
|
@@ -198,3 +210,18 @@ class SiftClient(
|
|
|
198
210
|
def rest_client(self) -> RestClient:
|
|
199
211
|
"""The REST client used by the SiftClient for making REST API calls."""
|
|
200
212
|
return self._rest_client
|
|
213
|
+
|
|
214
|
+
@property
|
|
215
|
+
def app_url(self) -> str | None:
|
|
216
|
+
"""The Sift web-app origin for this client, or None if it can't be determined.
|
|
217
|
+
|
|
218
|
+
Uses the explicit override passed at construction when set, otherwise
|
|
219
|
+
derives the origin from the REST host for known Sift deployments (e.g.
|
|
220
|
+
``https://api.siftstack.com`` -> ``https://app.siftstack.com``). Returns
|
|
221
|
+
None for unrecognized hosts with no override.
|
|
222
|
+
|
|
223
|
+
# TODO: Add a ``WithAppPage`` mixin on BaseType so resources (TestReport,
|
|
224
|
+
# Run, ...) can expose their own web-app link from ``_client.app_url`` plus
|
|
225
|
+
# a per-type path, instead of callers assembling paths by hand.
|
|
226
|
+
"""
|
|
227
|
+
return frontend_origin_for_api(self.rest_client.base_url, override=self._app_url)
|
sift_client/pytest_plugin.py
CHANGED
|
@@ -15,6 +15,7 @@ from sift_client.errors import SiftWarning
|
|
|
15
15
|
from sift_client.sift_types.test_report import ErrorInfo, TestStatus
|
|
16
16
|
from sift_client.util.test_results import ReportContext
|
|
17
17
|
from sift_client.util.test_results.context_manager import (
|
|
18
|
+
_quiet_fork_stderr,
|
|
18
19
|
format_assertion_message,
|
|
19
20
|
format_truncated_traceback,
|
|
20
21
|
)
|
|
@@ -42,6 +43,12 @@ if TYPE_CHECKING:
|
|
|
42
43
|
|
|
43
44
|
REPORT_CONTEXT: Any = None
|
|
44
45
|
|
|
46
|
+
# Set at session end with the resolved (real) report id/URL when online and
|
|
47
|
+
# uploaded. Read from a project's conftest in a later hook (e.g.
|
|
48
|
+
# ``pytest_unconfigure``) to post the link, write a file, etc.
|
|
49
|
+
SIFT_REPORT_ID_STASH_KEY = pytest.StashKey[str]()
|
|
50
|
+
SIFT_REPORT_URL_STASH_KEY = pytest.StashKey[str]()
|
|
51
|
+
|
|
45
52
|
_STASH_MISSING = object()
|
|
46
53
|
|
|
47
54
|
_PARAMETRIZE_PATH_KEY = pytest.StashKey[Tuple[str, ...]]()
|
|
@@ -297,6 +304,33 @@ _REST_URI = _Option(
|
|
|
297
304
|
"this ini value.",
|
|
298
305
|
)
|
|
299
306
|
|
|
307
|
+
_REPORT_URL_BASE = _Option(
|
|
308
|
+
cli_flag="--sift-report-url-base",
|
|
309
|
+
ini_name="sift_report_url_base",
|
|
310
|
+
cli_help="Sift web-app origin used to build the clickable report link in the "
|
|
311
|
+
"terminal footer (e.g. https://app.siftstack.com). Set this for on-prem or "
|
|
312
|
+
"custom deployments whose API host can't be mapped to a frontend "
|
|
313
|
+
"automatically. Also honored via the SIFT_APP_URL env var. When unset, the "
|
|
314
|
+
"link is derived from the REST URI for known Sift hosts.",
|
|
315
|
+
ini_help="Default for --sift-report-url-base. The Sift web-app origin used to "
|
|
316
|
+
"build the report link in the terminal footer. Also honored via the "
|
|
317
|
+
"SIFT_APP_URL env var. When unset, the link is derived from the REST URI for "
|
|
318
|
+
"known Sift hosts.",
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
_OPEN = _Option(
|
|
322
|
+
cli_flag="--sift-open-report",
|
|
323
|
+
ini_name="sift_open_report",
|
|
324
|
+
action="store_true",
|
|
325
|
+
cli_help="Open the resulting Sift test report in a browser at session end. "
|
|
326
|
+
"Online mode only; no-op when the report URL can't be resolved. Intended for "
|
|
327
|
+
"local development.",
|
|
328
|
+
ini_help="When true, open the report in a browser at session end (online only). "
|
|
329
|
+
"Defaults to false.",
|
|
330
|
+
ini_type="bool",
|
|
331
|
+
ini_default=False,
|
|
332
|
+
)
|
|
333
|
+
|
|
300
334
|
_AUTOUSE = _Option(
|
|
301
335
|
ini_name="sift_autouse",
|
|
302
336
|
ini_help="Default for the Sift autouse fixtures (report_context, step, "
|
|
@@ -350,6 +384,8 @@ _OPTIONS: tuple[_Option, ...] = (
|
|
|
350
384
|
_DISABLED,
|
|
351
385
|
_GRPC_URI,
|
|
352
386
|
_REST_URI,
|
|
387
|
+
_REPORT_URL_BASE,
|
|
388
|
+
_OPEN,
|
|
353
389
|
_AUTOUSE,
|
|
354
390
|
_PACKAGE_STEP,
|
|
355
391
|
_MODULE_STEP,
|
|
@@ -445,6 +481,305 @@ def _is_disabled(pytestconfig: pytest.Config | None) -> bool:
|
|
|
445
481
|
return os.getenv("SIFT_DISABLED", "").lower() in ("1", "true", "yes")
|
|
446
482
|
|
|
447
483
|
|
|
484
|
+
def _sdk_version() -> str:
|
|
485
|
+
"""Return the installed ``sift_stack_py`` version, or ``"unknown"``."""
|
|
486
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
487
|
+
|
|
488
|
+
try:
|
|
489
|
+
return version("sift_stack_py")
|
|
490
|
+
except PackageNotFoundError:
|
|
491
|
+
return "unknown"
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def _mode_label(config: pytest.Config) -> str:
|
|
495
|
+
"""Resolve the active mode for the terminal header: disabled > offline > online."""
|
|
496
|
+
if _is_disabled(config):
|
|
497
|
+
return "disabled"
|
|
498
|
+
if _is_offline(config):
|
|
499
|
+
return "offline"
|
|
500
|
+
return "online"
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def pytest_report_header(config: pytest.Config) -> str | None:
|
|
504
|
+
"""Emit a session-start header with the SDK version and active mode.
|
|
505
|
+
|
|
506
|
+
Suppressed under ``-q`` (negative verbosity), matching how pytest hides its
|
|
507
|
+
own platform/plugin header.
|
|
508
|
+
"""
|
|
509
|
+
if config.get_verbosity() < 0:
|
|
510
|
+
return None
|
|
511
|
+
return f"Sift: sift-stack-py {_sdk_version()} — {_mode_label(config)} mode"
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
def _resolve_real_report_id(context: Any) -> str | None:
|
|
515
|
+
"""Resolve the real server-side report id for the online footer link.
|
|
516
|
+
|
|
517
|
+
In synchronous online mode (``--sift-log-file=false``) the report is created
|
|
518
|
+
directly against the API, so ``report.id_`` is already the real id. In the
|
|
519
|
+
default incremental mode the report is created through the simulate path
|
|
520
|
+
(a client-side UUID) and the background worker maps it to the real id on
|
|
521
|
+
replay, recording it in the ``<log>.tracking`` sidecar's ``id_map``. By the
|
|
522
|
+
time this footer runs the session-scoped report context has torn down and
|
|
523
|
+
the worker has drained, so the sidecar is final.
|
|
524
|
+
|
|
525
|
+
Returns ``None`` when the worker never mapped the report (e.g. it died before
|
|
526
|
+
replaying the create), meaning no real report exists to link.
|
|
527
|
+
"""
|
|
528
|
+
report = context.report
|
|
529
|
+
if not report.id_:
|
|
530
|
+
# No id was ever assigned (unset/empty); nothing to link.
|
|
531
|
+
return None
|
|
532
|
+
sim_id = str(report.id_)
|
|
533
|
+
if not getattr(report, "is_simulated", False):
|
|
534
|
+
return sim_id
|
|
535
|
+
log_file = getattr(context, "log_file", None)
|
|
536
|
+
if log_file is None:
|
|
537
|
+
return None
|
|
538
|
+
from sift_client._internal.low_level_wrappers._test_results_log import LogTracking
|
|
539
|
+
|
|
540
|
+
return LogTracking.load(log_file).id_map.get(sim_id)
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
_LABEL_WIDTH = 13
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def _sift_kv(terminalreporter: Any, label: str, value: str, **value_markup: bool) -> None:
|
|
547
|
+
"""Write an indented ``label value`` row, bolding the label.
|
|
548
|
+
|
|
549
|
+
``value_markup`` (e.g. ``green=True``, ``cyan=True``) styles only the value.
|
|
550
|
+
Color is dropped automatically when the terminal has no markup (not a TTY or
|
|
551
|
+
``--color=no``), so captured/CI output stays plain text.
|
|
552
|
+
"""
|
|
553
|
+
terminalreporter.write(" ")
|
|
554
|
+
terminalreporter.write(f"{label:<{_LABEL_WIDTH}}", bold=True)
|
|
555
|
+
terminalreporter.write_line(value, **value_markup)
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
# Step-count breakdown order and labels for the footer's "Steps" row.
|
|
559
|
+
_STEP_COUNT_ORDER: tuple[tuple[TestStatus, str], ...] = (
|
|
560
|
+
(TestStatus.PASSED, "passed"),
|
|
561
|
+
(TestStatus.FAILED, "failed"),
|
|
562
|
+
(TestStatus.ERROR, "error"),
|
|
563
|
+
(TestStatus.ABORTED, "aborted"),
|
|
564
|
+
(TestStatus.SKIPPED, "skipped"),
|
|
565
|
+
(TestStatus.IN_PROGRESS, "in progress"),
|
|
566
|
+
)
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
# Per-status color for the footer's step breakdown: green pass, red
|
|
570
|
+
# failure/error/abort, yellow skip; in-progress (and anything else) stays plain.
|
|
571
|
+
_STEP_STATUS_MARKUP: dict[TestStatus, dict[str, bool]] = {
|
|
572
|
+
TestStatus.PASSED: {"green": True},
|
|
573
|
+
TestStatus.FAILED: {"red": True},
|
|
574
|
+
TestStatus.ERROR: {"red": True},
|
|
575
|
+
TestStatus.ABORTED: {"red": True},
|
|
576
|
+
TestStatus.SKIPPED: {"yellow": True},
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
def _step_count_segments(counts: Any) -> list[tuple[str, dict[str, bool]]]:
|
|
581
|
+
"""Build ``(text, markup)`` segments for a step tally, non-zero only."""
|
|
582
|
+
return [
|
|
583
|
+
(f"{counts.get(status, 0)} {label}", _STEP_STATUS_MARKUP.get(status, {}))
|
|
584
|
+
for status, label in _STEP_COUNT_ORDER
|
|
585
|
+
if counts.get(status, 0)
|
|
586
|
+
]
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
def _measurement_segments(counts: Any) -> list[tuple[str, dict[str, bool]]]:
|
|
590
|
+
"""Build ``(text, markup)`` segments for a measurement tally, non-zero only."""
|
|
591
|
+
segments: list[tuple[str, dict[str, bool]]] = []
|
|
592
|
+
if counts.get(True, 0):
|
|
593
|
+
segments.append((f"{counts[True]} passed", {"green": True}))
|
|
594
|
+
if counts.get(False, 0):
|
|
595
|
+
segments.append((f"{counts[False]} failed", {"red": True}))
|
|
596
|
+
return segments
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
def _write_count_row(
|
|
600
|
+
terminalreporter: Any, label: str, segments: list[tuple[str, dict[str, bool]]]
|
|
601
|
+
) -> None:
|
|
602
|
+
"""Write a ``label a · b · c`` row, applying each segment's color markup."""
|
|
603
|
+
terminalreporter.write(" ")
|
|
604
|
+
terminalreporter.write(f"{label:<{_LABEL_WIDTH}}", bold=True)
|
|
605
|
+
for index, (text, markup) in enumerate(segments):
|
|
606
|
+
if index:
|
|
607
|
+
terminalreporter.write(" · ")
|
|
608
|
+
terminalreporter.write(text, **markup)
|
|
609
|
+
terminalreporter.write_line("")
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
def _report_panel_title(report: Any, terminalreporter: Any) -> str:
|
|
613
|
+
"""``Sift report · <name>`` for the section rule, truncated to the terminal width.
|
|
614
|
+
|
|
615
|
+
The report name embeds a timestamp (and, for invocation-based runs, the
|
|
616
|
+
pytest args), so a long name is truncated with an ellipsis to keep the
|
|
617
|
+
separator line from wrapping.
|
|
618
|
+
"""
|
|
619
|
+
base = "Sift report"
|
|
620
|
+
name = getattr(report, "name", None)
|
|
621
|
+
if not name:
|
|
622
|
+
return base
|
|
623
|
+
title = f"{base} · {name}"
|
|
624
|
+
fullwidth = getattr(getattr(terminalreporter, "_tw", None), "fullwidth", 80)
|
|
625
|
+
# Reserve room for the separator characters and spaces write_sep adds.
|
|
626
|
+
limit = max(len(base), fullwidth - 8)
|
|
627
|
+
if len(title) > limit:
|
|
628
|
+
title = title[: limit - 1] + "…"
|
|
629
|
+
return title
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
def _maybe_open_report(url: str) -> None:
|
|
633
|
+
"""Best-effort open the report URL in a browser (for ``--sift-open-report``).
|
|
634
|
+
|
|
635
|
+
Skipped on CI or non-interactive sessions so a committed ``sift_open_report``
|
|
636
|
+
setting can't spawn a browser on a headless agent; the flag is meant for
|
|
637
|
+
local development.
|
|
638
|
+
"""
|
|
639
|
+
import sys
|
|
640
|
+
import webbrowser
|
|
641
|
+
|
|
642
|
+
if os.environ.get("CI") or not sys.stdout.isatty():
|
|
643
|
+
return
|
|
644
|
+
try:
|
|
645
|
+
# webbrowser.open forks/execs the platform opener while the gRPC client's
|
|
646
|
+
# background threads are live; redirect fd 2 across the fork to swallow
|
|
647
|
+
# gRPC's prefork notice (same treatment as the plugin's other fork sites).
|
|
648
|
+
with _quiet_fork_stderr():
|
|
649
|
+
webbrowser.open(url)
|
|
650
|
+
except Exception:
|
|
651
|
+
# Headless / no browser available: opening is a convenience, never fatal.
|
|
652
|
+
pass
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
def pytest_terminal_summary(terminalreporter: Any, exitstatus: int, config: pytest.Config) -> None:
|
|
656
|
+
"""Emit a session-end Sift report summary, adapting per mode.
|
|
657
|
+
|
|
658
|
+
The printed panel is suppressed under ``-q``, but programmatic side effects
|
|
659
|
+
(stashing the report ref for ``conftest.py``, ``--sift-open-report``) still run so
|
|
660
|
+
other plugins and CI steps can consume the result. The panel shows the
|
|
661
|
+
outcome (green/red), step and measurement tallies, and a per-mode action: a
|
|
662
|
+
report link (online), the upload command (offline), or a disabled note.
|
|
663
|
+
"""
|
|
664
|
+
quiet = config.get_verbosity() < 0
|
|
665
|
+
|
|
666
|
+
if _is_disabled(config):
|
|
667
|
+
if not quiet:
|
|
668
|
+
terminalreporter.write_sep("=", "Sift", cyan=True, bold=True)
|
|
669
|
+
terminalreporter.write_line("Sift disabled — no test report created.")
|
|
670
|
+
return
|
|
671
|
+
|
|
672
|
+
context = REPORT_CONTEXT
|
|
673
|
+
if context is None:
|
|
674
|
+
# No gated test ran, so no report context was created. Nothing to show.
|
|
675
|
+
return
|
|
676
|
+
|
|
677
|
+
log_file = getattr(context, "log_file", None)
|
|
678
|
+
offline = _is_offline(config)
|
|
679
|
+
|
|
680
|
+
# Resolve the report link first so stashing and --sift-open-report run even under
|
|
681
|
+
# -q (programmatic consumers don't care about verbosity). Truthiness, not
|
|
682
|
+
# ``is not None``: a resolved-but-empty id (degenerate sidecar mapping, unset
|
|
683
|
+
# proto field) must fall through to the "not uploaded" path, not produce a
|
|
684
|
+
# ``/test-results/`` link.
|
|
685
|
+
report_id = None if offline else _resolve_real_report_id(context)
|
|
686
|
+
report_url = (
|
|
687
|
+
f"{context.client.app_url}/test-results/{report_id}"
|
|
688
|
+
if report_id and context.client.app_url
|
|
689
|
+
else None
|
|
690
|
+
)
|
|
691
|
+
if report_id:
|
|
692
|
+
config.stash[SIFT_REPORT_ID_STASH_KEY] = report_id
|
|
693
|
+
if report_url is not None:
|
|
694
|
+
config.stash[SIFT_REPORT_URL_STASH_KEY] = report_url
|
|
695
|
+
if _option_or_ini(config, _OPEN):
|
|
696
|
+
_maybe_open_report(report_url)
|
|
697
|
+
|
|
698
|
+
if quiet:
|
|
699
|
+
return
|
|
700
|
+
|
|
701
|
+
failed = bool(getattr(context, "any_failures", False))
|
|
702
|
+
status_word, status_markup = (
|
|
703
|
+
("FAILED", {"red": True, "bold": True})
|
|
704
|
+
if failed
|
|
705
|
+
else ("PASSED", {"green": True, "bold": True})
|
|
706
|
+
)
|
|
707
|
+
# Offline results live only in the local log until replayed, so the status
|
|
708
|
+
# row calls that out instead of repeating the version (already in the header).
|
|
709
|
+
status_context = (
|
|
710
|
+
f"{_mode_label(config)} · not uploaded"
|
|
711
|
+
if offline
|
|
712
|
+
else f"{_mode_label(config)} · sift-stack-py {_sdk_version()}"
|
|
713
|
+
)
|
|
714
|
+
|
|
715
|
+
report = context.report
|
|
716
|
+
|
|
717
|
+
terminalreporter.write_sep(
|
|
718
|
+
"=", _report_panel_title(report, terminalreporter), cyan=True, bold=True
|
|
719
|
+
)
|
|
720
|
+
|
|
721
|
+
# Identity row: the test case (test path or pytest invocation).
|
|
722
|
+
if report.test_case:
|
|
723
|
+
_sift_kv(terminalreporter, "Test case", str(report.test_case))
|
|
724
|
+
|
|
725
|
+
# Status row: colored outcome, then compact mode context.
|
|
726
|
+
terminalreporter.write(" ")
|
|
727
|
+
terminalreporter.write(f"{'Status':<{_LABEL_WIDTH}}", bold=True)
|
|
728
|
+
terminalreporter.write(status_word, **status_markup)
|
|
729
|
+
terminalreporter.write_line(f" {status_context}")
|
|
730
|
+
|
|
731
|
+
# Step + measurement tallies (green pass, red failure, yellow skip).
|
|
732
|
+
_write_count_row(
|
|
733
|
+
terminalreporter,
|
|
734
|
+
"Steps",
|
|
735
|
+
_step_count_segments(context.step_status_counts) or [("no steps", {})],
|
|
736
|
+
)
|
|
737
|
+
measurement_segments = _measurement_segments(context.measurement_counts)
|
|
738
|
+
if measurement_segments:
|
|
739
|
+
_write_count_row(terminalreporter, "Measurements", measurement_segments)
|
|
740
|
+
|
|
741
|
+
# Provenance row: test system and operator.
|
|
742
|
+
system = " · ".join(part for part in (report.test_system_name, report.system_operator) if part)
|
|
743
|
+
if system:
|
|
744
|
+
_sift_kv(terminalreporter, "System", system)
|
|
745
|
+
|
|
746
|
+
# Local log file (write-through backup online, sole sink offline).
|
|
747
|
+
if log_file is not None:
|
|
748
|
+
_sift_kv(terminalreporter, "Log file", str(log_file))
|
|
749
|
+
|
|
750
|
+
if offline:
|
|
751
|
+
if log_file is not None:
|
|
752
|
+
terminalreporter.write_sep("-", "to upload to Sift")
|
|
753
|
+
terminalreporter.write_line(f" >> import-test-result-log {log_file}", cyan=True)
|
|
754
|
+
return
|
|
755
|
+
|
|
756
|
+
if not report_id:
|
|
757
|
+
# Incremental upload never mapped the report (the worker died before
|
|
758
|
+
# replaying the create), so there's no real report to link.
|
|
759
|
+
_sift_kv(
|
|
760
|
+
terminalreporter,
|
|
761
|
+
"Report",
|
|
762
|
+
f"not uploaded — replay with: import-test-result-log {log_file}",
|
|
763
|
+
yellow=True,
|
|
764
|
+
)
|
|
765
|
+
elif report_url is not None:
|
|
766
|
+
_sift_kv(terminalreporter, "Report", report_url, cyan=True)
|
|
767
|
+
else:
|
|
768
|
+
_sift_kv(
|
|
769
|
+
terminalreporter,
|
|
770
|
+
"Report",
|
|
771
|
+
f"id {report_id} (set sift_report_url_base for a clickable link)",
|
|
772
|
+
)
|
|
773
|
+
|
|
774
|
+
if report_id and getattr(context, "replay_incomplete", False) and log_file is not None:
|
|
775
|
+
_sift_kv(
|
|
776
|
+
terminalreporter,
|
|
777
|
+
"",
|
|
778
|
+
f"may be incomplete — finish with: import-test-result-log {log_file}",
|
|
779
|
+
yellow=True,
|
|
780
|
+
)
|
|
781
|
+
|
|
782
|
+
|
|
448
783
|
def _sift_enabled_for(node: pytest.Item | pytest.Collector, default: bool) -> bool:
|
|
449
784
|
"""Resolve the Sift gate for a node: sift_exclude > sift_include > default.
|
|
450
785
|
|
|
@@ -806,6 +1141,10 @@ def sift_client(pytestconfig: pytest.Config) -> SiftClient:
|
|
|
806
1141
|
)
|
|
807
1142
|
for env in missing:
|
|
808
1143
|
resolved[env] = _OFFLINE_DEFAULTS[env]
|
|
1144
|
+
# Web-app origin for the report link: the sift_report_url_base CLI/ini option
|
|
1145
|
+
# wins, then the SIFT_APP_URL env var, else host-based derivation in
|
|
1146
|
+
# SiftClient.app_url.
|
|
1147
|
+
report_url_base = _option_or_ini(pytestconfig, _REPORT_URL_BASE) or os.getenv("SIFT_APP_URL")
|
|
809
1148
|
# `or ""` is unreachable in practice since the `missing` check above guarantees
|
|
810
1149
|
# non-None values
|
|
811
1150
|
return SiftClient(
|
|
@@ -813,6 +1152,7 @@ def sift_client(pytestconfig: pytest.Config) -> SiftClient:
|
|
|
813
1152
|
api_key=resolved.get("SIFT_API_KEY") or "",
|
|
814
1153
|
grpc_url=resolved.get("SIFT_GRPC_URI") or "",
|
|
815
1154
|
rest_url=resolved.get("SIFT_REST_URI") or "",
|
|
1155
|
+
app_url=report_url_base or None,
|
|
816
1156
|
)
|
|
817
1157
|
)
|
|
818
1158
|
|
|
@@ -410,6 +410,38 @@ class TestMeasurement(BaseType[TestMeasurementProto, "TestMeasurement"], Simulat
|
|
|
410
410
|
# Set by the low-level wrapper when this instance came from the simulate path
|
|
411
411
|
_simulated: bool = False
|
|
412
412
|
|
|
413
|
+
def __str__(self) -> str:
|
|
414
|
+
"""Human-readable form: ``[STATUS] name = value [unit] (bounds)``.
|
|
415
|
+
|
|
416
|
+
Used for failure messages, logs, and the REPL. The string omits whichever
|
|
417
|
+
parts aren't set (no unit, no bounds), and falls back to ``?`` if no
|
|
418
|
+
value type is populated. The status prefix reflects ``self.passed``.
|
|
419
|
+
"""
|
|
420
|
+
status = "PASSED" if self.passed else "FAILED"
|
|
421
|
+
if self.numeric_value is not None:
|
|
422
|
+
value = f"{self.numeric_value}"
|
|
423
|
+
if self.unit:
|
|
424
|
+
value += f" {self.unit}"
|
|
425
|
+
elif self.string_value is not None:
|
|
426
|
+
value = repr(self.string_value)
|
|
427
|
+
elif self.boolean_value is not None:
|
|
428
|
+
value = str(self.boolean_value).lower()
|
|
429
|
+
else:
|
|
430
|
+
value = "?"
|
|
431
|
+
bounds = ""
|
|
432
|
+
nb = self.numeric_bounds
|
|
433
|
+
if nb is not None:
|
|
434
|
+
parts: list[str] = []
|
|
435
|
+
if nb.min is not None:
|
|
436
|
+
parts.append(f"min {nb.min}")
|
|
437
|
+
if nb.max is not None:
|
|
438
|
+
parts.append(f"max {nb.max}")
|
|
439
|
+
if parts:
|
|
440
|
+
bounds = f" ({', '.join(parts)})"
|
|
441
|
+
elif self.string_expected_value:
|
|
442
|
+
bounds = f" (expected {self.string_expected_value!r})"
|
|
443
|
+
return f"[{status}] {self.name} = {value}{bounds}"
|
|
444
|
+
|
|
413
445
|
@classmethod
|
|
414
446
|
def _from_proto(
|
|
415
447
|
cls, proto: TestMeasurementProto, sift_client: SiftClient | None = None
|
|
@@ -24,6 +24,7 @@ class SiftConnectionConfig:
|
|
|
24
24
|
api_key: str,
|
|
25
25
|
use_ssl: bool = True,
|
|
26
26
|
cert_via_openssl: bool = False,
|
|
27
|
+
app_url: str | None = None,
|
|
27
28
|
):
|
|
28
29
|
"""Initialize the connection configuration.
|
|
29
30
|
|
|
@@ -33,12 +34,17 @@ class SiftConnectionConfig:
|
|
|
33
34
|
api_key: The API key for authentication.
|
|
34
35
|
use_ssl: Whether to use SSL/TLS for secure connections.
|
|
35
36
|
cert_via_openssl: Whether to use OpenSSL for certificate validation.
|
|
37
|
+
app_url: The Sift web-app origin (e.g. ``https://app.siftstack.com``).
|
|
38
|
+
Set this for on-prem or custom deployments whose API host can't be
|
|
39
|
+
mapped to a frontend automatically. When unset, the web-app URL is
|
|
40
|
+
derived from ``rest_url`` for known hosts.
|
|
36
41
|
"""
|
|
37
42
|
self.api_key = api_key
|
|
38
43
|
self.grpc_url = grpc_url
|
|
39
44
|
self.rest_url = rest_url
|
|
40
45
|
self.use_ssl = use_ssl
|
|
41
46
|
self.cert_via_openssl = cert_via_openssl
|
|
47
|
+
self.app_url = app_url
|
|
42
48
|
|
|
43
49
|
def get_grpc_config(self):
|
|
44
50
|
"""Create and return a GrpcConfig with the current settings.
|
|
@@ -8,6 +8,7 @@ import subprocess
|
|
|
8
8
|
import tempfile
|
|
9
9
|
import traceback
|
|
10
10
|
import warnings
|
|
11
|
+
from collections import Counter
|
|
11
12
|
from contextlib import AbstractContextManager, contextmanager
|
|
12
13
|
from datetime import datetime, timezone
|
|
13
14
|
from pathlib import Path
|
|
@@ -19,6 +20,7 @@ from sift_client.errors import SiftWarning
|
|
|
19
20
|
from sift_client.sift_types.test_report import (
|
|
20
21
|
ErrorInfo,
|
|
21
22
|
NumericBounds,
|
|
23
|
+
TestMeasurement,
|
|
22
24
|
TestMeasurementCreate,
|
|
23
25
|
TestReport,
|
|
24
26
|
TestReportCreate,
|
|
@@ -140,6 +142,19 @@ class ReportContext(AbstractContextManager):
|
|
|
140
142
|
step_number_at_depth: dict[int, int]
|
|
141
143
|
open_step_results: dict[str, bool]
|
|
142
144
|
any_failures: bool
|
|
145
|
+
# Every step created in this report (including hierarchy/parametrize
|
|
146
|
+
# parents), retained after close so end-of-run summaries can tally final
|
|
147
|
+
# statuses. ``update`` mutates step instances in place, so these references
|
|
148
|
+
# reflect late status changes (e.g. a teardown-phase failure).
|
|
149
|
+
created_steps: list[TestStep]
|
|
150
|
+
# Every measurement recorded in this report, retained for end-of-run
|
|
151
|
+
# summaries. Appended in ``NewStep.measure``. A measurement's ``passed`` is
|
|
152
|
+
# fixed at creation, so the retained references stay accurate.
|
|
153
|
+
created_measurements: list[TestMeasurement]
|
|
154
|
+
# Set True in ``__exit__`` when the background replay worker timed out or
|
|
155
|
+
# exited non-zero, so callers (e.g. the pytest plugin footer) can flag that
|
|
156
|
+
# the uploaded report may be missing entries.
|
|
157
|
+
replay_incomplete: bool = False
|
|
143
158
|
_import_proc: subprocess.Popen | None = None
|
|
144
159
|
# Seconds to wait for the import worker subprocess to finish uploading
|
|
145
160
|
# the JSONL backlog at session end before killing it. Tests substitute
|
|
@@ -184,6 +199,9 @@ class ReportContext(AbstractContextManager):
|
|
|
184
199
|
self.step_number_at_depth = {}
|
|
185
200
|
self.open_step_results = {}
|
|
186
201
|
self.any_failures = False
|
|
202
|
+
self.created_steps = []
|
|
203
|
+
self.created_measurements = []
|
|
204
|
+
self.replay_incomplete = False
|
|
187
205
|
|
|
188
206
|
if log_file is True:
|
|
189
207
|
tmp = tempfile.NamedTemporaryFile(suffix=".jsonl", delete=False)
|
|
@@ -279,6 +297,7 @@ class ReportContext(AbstractContextManager):
|
|
|
279
297
|
except subprocess.TimeoutExpired:
|
|
280
298
|
self._import_proc.kill()
|
|
281
299
|
self._import_proc.wait()
|
|
300
|
+
self.replay_incomplete = True
|
|
282
301
|
warnings.warn(
|
|
283
302
|
f"Sift import worker did not exit in "
|
|
284
303
|
f"{self._import_proc_timeout}s; killing it. "
|
|
@@ -289,6 +308,7 @@ class ReportContext(AbstractContextManager):
|
|
|
289
308
|
log_replay_instructions(self.log_file)
|
|
290
309
|
return True # Ensures the session is marked as passed in pytest
|
|
291
310
|
if self._import_proc.returncode != 0:
|
|
311
|
+
self.replay_incomplete = True
|
|
292
312
|
stderr_text = (
|
|
293
313
|
stderr_bytes.decode("utf-8", errors="replace").strip() if stderr_bytes else ""
|
|
294
314
|
)
|
|
@@ -311,6 +331,23 @@ class ReportContext(AbstractContextManager):
|
|
|
311
331
|
"""
|
|
312
332
|
return self.report.is_simulated
|
|
313
333
|
|
|
334
|
+
@property
|
|
335
|
+
def step_status_counts(self) -> Counter[TestStatus]:
|
|
336
|
+
"""Tally of every created step by its current status.
|
|
337
|
+
|
|
338
|
+
Includes hierarchy/parametrize parent steps. Read at the end of a run for
|
|
339
|
+
summaries; reflects late status changes since steps are mutated in place.
|
|
340
|
+
"""
|
|
341
|
+
return Counter(step.status for step in self.created_steps)
|
|
342
|
+
|
|
343
|
+
@property
|
|
344
|
+
def measurement_counts(self) -> Counter[bool]:
|
|
345
|
+
"""Tally of recorded measurements keyed by ``passed`` (True/False).
|
|
346
|
+
|
|
347
|
+
Read at the end of a run for summaries.
|
|
348
|
+
"""
|
|
349
|
+
return Counter(m.passed for m in self.created_measurements)
|
|
350
|
+
|
|
314
351
|
def new_step(
|
|
315
352
|
self,
|
|
316
353
|
name: str,
|
|
@@ -378,6 +415,8 @@ class ReportContext(AbstractContextManager):
|
|
|
378
415
|
)
|
|
379
416
|
self.step_stack.append(step)
|
|
380
417
|
self.open_step_results[step.step_path] = True
|
|
418
|
+
# Retained for end-of-run tallies; never popped (unlike step_stack).
|
|
419
|
+
self.created_steps.append(step)
|
|
381
420
|
|
|
382
421
|
return step
|
|
383
422
|
|
|
@@ -388,6 +427,10 @@ class ReportContext(AbstractContextManager):
|
|
|
388
427
|
self.open_step_results[step.step_path] = False
|
|
389
428
|
self.any_failures = True
|
|
390
429
|
|
|
430
|
+
def record_measurement(self, measurement: TestMeasurement) -> None:
|
|
431
|
+
"""Retain a recorded measurement for end-of-run summaries."""
|
|
432
|
+
self.created_measurements.append(measurement)
|
|
433
|
+
|
|
391
434
|
def mark_step_failed_after_close(self, step: TestStep):
|
|
392
435
|
"""Mark a step's parent as failed after the step has already been popped from the stack.
|
|
393
436
|
|
|
@@ -466,6 +509,9 @@ class NewStep(AbstractContextManager):
|
|
|
466
509
|
# substep / ``report_outcome`` failures are intentionally not folded
|
|
467
510
|
# in here (see ``measurements_passed`` vs ``passed``).
|
|
468
511
|
self._failed_measurement_count = 0
|
|
512
|
+
# Out-of-bounds measurements recorded on this step, retained so
|
|
513
|
+
# ``fail_if_measurements_failed`` can name them in the failure message.
|
|
514
|
+
self._failed_measurements: list[TestMeasurement] = []
|
|
469
515
|
|
|
470
516
|
def __enter__(self):
|
|
471
517
|
"""Enter the context manager to create a new step.
|
|
@@ -487,9 +533,7 @@ class NewStep(AbstractContextManager):
|
|
|
487
533
|
"""
|
|
488
534
|
return self._failed_measurement_count == 0
|
|
489
535
|
|
|
490
|
-
def fail_if_measurements_failed(
|
|
491
|
-
self, message: str = "one or more measurements out of bounds"
|
|
492
|
-
) -> None:
|
|
536
|
+
def fail_if_measurements_failed(self, message: str = "measurements out of bounds") -> None:
|
|
493
537
|
"""Fail the pytest test if any measurement on this step was out of bounds.
|
|
494
538
|
|
|
495
539
|
Use instead of ``assert step.measurements_passed``: it fails via
|
|
@@ -497,12 +541,18 @@ class NewStep(AbstractContextManager):
|
|
|
497
541
|
assertion message to ``error_info``. No-op when every measurement
|
|
498
542
|
passed. Call once at the end of the test so every measurement is still
|
|
499
543
|
recorded before the failure fires.
|
|
544
|
+
|
|
545
|
+
The failure message names each out-of-bounds measurement with its
|
|
546
|
+
recorded value and bounds. ``message`` is used as the header line.
|
|
500
547
|
"""
|
|
501
548
|
if self.measurements_passed:
|
|
502
549
|
return
|
|
503
550
|
import pytest
|
|
504
551
|
|
|
505
|
-
|
|
552
|
+
failed = self._failed_measurements
|
|
553
|
+
header = f"{message} ({len(failed)}):" if failed else message
|
|
554
|
+
body = [f" - {m}" for m in failed]
|
|
555
|
+
pytest.fail("\n".join([header, *body]), pytrace=False)
|
|
506
556
|
|
|
507
557
|
def update_step_from_result(
|
|
508
558
|
self,
|
|
@@ -662,8 +712,10 @@ class NewStep(AbstractContextManager):
|
|
|
662
712
|
create, log_file=self.report_context.log_file
|
|
663
713
|
)
|
|
664
714
|
self.report_context.record_step_outcome(measurement.passed, self.current_step)
|
|
715
|
+
self.report_context.record_measurement(measurement)
|
|
665
716
|
if not measurement.passed:
|
|
666
717
|
self._failed_measurement_count += 1
|
|
718
|
+
self._failed_measurements.append(measurement)
|
|
667
719
|
|
|
668
720
|
return measurement.passed
|
|
669
721
|
|
|
@@ -330,20 +330,21 @@ sift/webhooks/v1/webhooks_pb2.pyi,sha256=nBxUmHlHRs4oBjiccaw_02e8voynjuvcAfBO98_
|
|
|
330
330
|
sift/webhooks/v1/webhooks_pb2_grpc.py,sha256=AV2o51-Wabb6C6iTFZ7AQHqALi7ix2xfZeLuMON87FI,21905
|
|
331
331
|
sift/webhooks/v1/webhooks_pb2_grpc.pyi,sha256=5Onwi3F0iuSlo1StR8FKI5TeaZlK3HGdViqXCjNd8hg,13061
|
|
332
332
|
sift_client/__init__.py,sha256=BUYPbnSK-zM72XbCJVV2W7wQI9Q-ulmtuNLRvFoXLVE,3610
|
|
333
|
-
sift_client/client.py,sha256=
|
|
333
|
+
sift_client/client.py,sha256=4dej-vJgCRdpS2Hs-jY0BcTqzsgvxJdM_gKxST_GlNs,8221
|
|
334
334
|
sift_client/config.py,sha256=cFZ4XtAlLIr-V91OUqMwMK2W5ZmyRW65_DU06fqoPUM,1008
|
|
335
335
|
sift_client/errors.py,sha256=lwvdE53SnbJkKeWPRsuyjeLlYn7QFRHKOoE_SwJbYig,564
|
|
336
336
|
sift_client/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
337
|
-
sift_client/pytest_plugin.py,sha256=
|
|
337
|
+
sift_client/pytest_plugin.py,sha256=S2yafu5uaIVZoHxVpJkJmK6EFmwu9mlAiGTgf6e7foc,59255
|
|
338
338
|
sift_client/_internal/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
339
339
|
sift_client/_internal/gen_pyi.py,sha256=VQzcHMGAO8hzQiQ0twlxTDnex3QQLpe0ctitYSXorUU,10270
|
|
340
|
-
sift_client/_internal/rest.py,sha256=
|
|
340
|
+
sift_client/_internal/rest.py,sha256=L4f1k3IounQhzBRWa_2XuzSnkWvimYowwpczx3r1PXU,2838
|
|
341
341
|
sift_client/_internal/sync_wrapper.py,sha256=H6zeFLPf5CczHC44KSBjM6Lge8KbV2dOS2qWRYCTNoE,5273
|
|
342
342
|
sift_client/_internal/time.py,sha256=gt7I4IS8xqLBFwGBkSBeN4DohKIIOPEArCeH1YcYUe4,1492
|
|
343
|
+
sift_client/_internal/urls.py,sha256=zxezIhdQsGs3dL0MRi2w9fhBsLkAKVIbcJBItxRIJvY,2136
|
|
343
344
|
sift_client/_internal/grpc_transport/__init__.py,sha256=9reBbl6u_h9hoFCq6nNkQmxH5ce-8JA2s1iKpNl7_0g,506
|
|
344
345
|
sift_client/_internal/grpc_transport/_retry.py,sha256=EMLJcrM_VSLz5zVQPMaMzRQBRMmTLzfxSm-X_JCZB18,2378
|
|
345
346
|
sift_client/_internal/grpc_transport/keepalive.py,sha256=EfSo4Go9VnSB7cnv7f9FK2__6su1XoJlaAgEj-NEUF8,1036
|
|
346
|
-
sift_client/_internal/grpc_transport/transport.py,sha256=
|
|
347
|
+
sift_client/_internal/grpc_transport/transport.py,sha256=mo69nu_JnbV4l6dEHWmBKNwWaSDvE_eqxQnRjSKkLfw,8837
|
|
347
348
|
sift_client/_internal/grpc_transport/transport_test.py,sha256=6RLBcoSwFjYNJ2ZVfZbdsO9cFCb44vairaaK_o49N-k,7452
|
|
348
349
|
sift_client/_internal/grpc_transport/_async_interceptors/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
349
350
|
sift_client/_internal/grpc_transport/_async_interceptors/base.py,sha256=wicPGrT7XJDgxIur0cr0soISOBi_vzCwXIJv8_mVogs,2475
|
|
@@ -418,12 +419,12 @@ sift_client/sift_types/report.py,sha256=0YoKR6tHao20hLKvTb78n7vR1Rpu-lF8H4vFP7SM
|
|
|
418
419
|
sift_client/sift_types/rule.py,sha256=pS6FSBFZIB9SHorpMm60AA7nKvOjVGv7BPYcyXEeNbU,13660
|
|
419
420
|
sift_client/sift_types/run.py,sha256=LqTO4Jo_05bJ54aGVI9RNkZcZTKKM6bha6c7ZCHGP18,8668
|
|
420
421
|
sift_client/sift_types/tag.py,sha256=yos_iqG63iI8rx6JWfr7SbUZ-6Y1Co1Lv44zgrsApyU,2111
|
|
421
|
-
sift_client/sift_types/test_report.py,sha256=
|
|
422
|
+
sift_client/sift_types/test_report.py,sha256=HYTlNrfLodLst-6Nll0Zpyi8AfeUHMZL9j3X0AUa-4E,26260
|
|
422
423
|
sift_client/sift_types/_mixins/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
423
424
|
sift_client/sift_types/_mixins/file_attachments.py,sha256=QRXgKjZH6ONymwoc9SKftmoFN6oOwHVLynAqwz8Yzh4,3539
|
|
424
425
|
sift_client/sift_types/_mixins/simulated.py,sha256=w93DOF_WYTBXgTGmskRftiMY1JvvYBx3ALPtKjxWhUQ,1289
|
|
425
426
|
sift_client/transport/__init__.py,sha256=YQFaoQ24FZlLVRAstYCgSWkS64qkcd6J-RRwPL0Ds9A,423
|
|
426
|
-
sift_client/transport/base_connection.py,sha256=
|
|
427
|
+
sift_client/transport/base_connection.py,sha256=bXBPK7B0hPKJEw6abhGDroswdQCaORSU-zz22HGUP60,3524
|
|
427
428
|
sift_client/transport/grpc_transport.py,sha256=0YV4nfwaRfsbKjHbPhHd8jCl4wnbGIvhOMH-93sGTO4,6906
|
|
428
429
|
sift_client/transport/rest_transport.py,sha256=PFh_n7wJyrRqnMJEsBx2gi4kUrShzEwwdDZKXz4tzsY,6320
|
|
429
430
|
sift_client/util/__init__.py,sha256=u0EeqAVhIPdKpijEaYVNXPK8moS-G20ConTyrgaYtEw,51
|
|
@@ -432,7 +433,7 @@ sift_client/util/metadata.py,sha256=E9OC-3VKOhrLjggEO1WYnATNeJq_84CRyXCLmbAp5Vc,
|
|
|
432
433
|
sift_client/util/util.py,sha256=kpqIb2oWtbU7OM0Y7PoFHVcP4FY0mF2sU55ji3NMzBs,2271
|
|
433
434
|
sift_client/util/test_results/__init__.py,sha256=8nviro4Jl3D3jhtARZYbkxGyxqn4F7VdmWU5dkyYGUc,5797
|
|
434
435
|
sift_client/util/test_results/bounds.py,sha256=0AGa83cE9sLmVuEEXvZhJLzbMor9CHKHEwaet0ejOng,5304
|
|
435
|
-
sift_client/util/test_results/context_manager.py,sha256=
|
|
436
|
+
sift_client/util/test_results/context_manager.py,sha256=hYRSk73xdX6Ny6o3uVGugrz32Zgr0urRCDFmjYMfg_Q,35624
|
|
436
437
|
sift_py/__init__.py,sha256=KyccGwcwQmcEHnuwMVZPT6AW6hSHCYSCvYPQYrfSSMs,31393
|
|
437
438
|
sift_py/_rest_test.py,sha256=hdPD17XoJTd0mrB_q8-IiiwTWKZu-Bn2EPj0KfSUbmc,2251
|
|
438
439
|
sift_py/error.py,sha256=YKH8QSIQQXeQAuuzxGuA4LLvn_NsVd-8mx2lGfCQAII,1386
|
|
@@ -563,9 +564,9 @@ sift_py/yaml/channel.py,sha256=fH4qy8qMOSpcov11Zf5g2golwUUJg1hTGTe5SWp13KM,6933
|
|
|
563
564
|
sift_py/yaml/report_templates.py,sha256=ytaLHeCgDptmVadWS_aWL3XASn24mMz11b47gQwCiQY,2702
|
|
564
565
|
sift_py/yaml/rule.py,sha256=BqJ-akCv-m05pJ1mX5D1cQiSdXhb_v3utt0gf68jYrY,12229
|
|
565
566
|
sift_py/yaml/utils.py,sha256=s8SHkvEJDi24wNYVaEeZtk_ziyTUPMQI-s45fcZtx5M,3158
|
|
566
|
-
sift_stack_py-0.17.0.
|
|
567
|
-
sift_stack_py-0.17.0.
|
|
568
|
-
sift_stack_py-0.17.0.
|
|
569
|
-
sift_stack_py-0.17.0.
|
|
570
|
-
sift_stack_py-0.17.0.
|
|
571
|
-
sift_stack_py-0.17.0.
|
|
567
|
+
sift_stack_py-0.17.0.dev1.dist-info/LICENSE,sha256=0wD7kHbRuEKXASAN5pEXM6LZAGebonVWcOSaY-kW_W8,1067
|
|
568
|
+
sift_stack_py-0.17.0.dev1.dist-info/METADATA,sha256=vJbJQ_XA4wwE7iCg802HpYbt31468xwM5VitGWcakIc,10436
|
|
569
|
+
sift_stack_py-0.17.0.dev1.dist-info/WHEEL,sha256=BNRMDyzLkkcmlv0J8ppDQkk2VED33SesJDynr9ED1gc,91
|
|
570
|
+
sift_stack_py-0.17.0.dev1.dist-info/entry_points.txt,sha256=0xoXN-qOSR2WmKV_zYQ6SUF2PhdBAyRvdV11GbsC0sY,91
|
|
571
|
+
sift_stack_py-0.17.0.dev1.dist-info/top_level.txt,sha256=bUvXP5rbzCmAZs1nmollLqDEJK9xLChtWvcF0YHTjFE,25
|
|
572
|
+
sift_stack_py-0.17.0.dev1.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
{sift_stack_py-0.17.0.dev0.dist-info → sift_stack_py-0.17.0.dev1.dist-info}/entry_points.txt
RENAMED
|
File without changes
|
|
File without changes
|