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.
@@ -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 = _clean_uri(config["uri"], use_ssl)
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=_clean_uri(config["uri"], use_ssl),
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 = _clean_uri(config["uri"], False)
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=_clean_uri(config["uri"], False),
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')}"
@@ -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.grpc_transport.transport import _clean_uri
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 = _clean_uri(uri, use_ssl)
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)
@@ -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
- pytest.fail(message, pytrace=False)
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
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: sift_stack_py
3
- Version: 0.17.0.dev0
3
+ Version: 0.17.0.dev1
4
4
  Summary: Python client library for the Sift API
5
5
  Maintainer-email: Sift Software Engineers <engineering@siftstack.com>
6
6
  License: Copyright (c) 2024 Azimuth Industries Inc.
@@ -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=MtN3WGVmTPPqpi0Gp8ZRygzZSPGtB1M79LqDEBEEiUw,6732
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=4A1Rq9Vr1TFtCPZC673f1SRSJ0b1rxgO-7CFg7sVBxU,45891
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=fglfy_I8MwjIdPjyc_oygCSkz8GuWX0y8vGTgPdm4e0,2867
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=wcpAqh67E1xxjJ_jsv0kSu4ZUDK_l5DZudRJ9Y8ZXU4,9363
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=wdMUzS1tnG4dSTbpcUmAR_Q0i2llKOyY-OestqeNsjY,24958
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=_6LjtsLRplhG_SC3ORnBGNI_i1VqEKivtB09QdzFOpU,3149
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=FJWbTtVUf8mLfE0K_9WRBF8rAPfoVYyn44qUaJm4_3w,32931
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.dev0.dist-info/LICENSE,sha256=0wD7kHbRuEKXASAN5pEXM6LZAGebonVWcOSaY-kW_W8,1067
567
- sift_stack_py-0.17.0.dev0.dist-info/METADATA,sha256=hK1LA4SndgXxuXx8czC3AdsoqHIHWIInHaYdD_h8P4c,10436
568
- sift_stack_py-0.17.0.dev0.dist-info/WHEEL,sha256=BNRMDyzLkkcmlv0J8ppDQkk2VED33SesJDynr9ED1gc,91
569
- sift_stack_py-0.17.0.dev0.dist-info/entry_points.txt,sha256=0xoXN-qOSR2WmKV_zYQ6SUF2PhdBAyRvdV11GbsC0sY,91
570
- sift_stack_py-0.17.0.dev0.dist-info/top_level.txt,sha256=bUvXP5rbzCmAZs1nmollLqDEJK9xLChtWvcF0YHTjFE,25
571
- sift_stack_py-0.17.0.dev0.dist-info/RECORD,,
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,,