Skip to content

Commit 0e5693c

Browse files
committed
Create a subprocess for uploading the report as it goes
1 parent ab62b5d commit 0e5693c

11 files changed

Lines changed: 983 additions & 630 deletions

File tree

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

Lines changed: 825 additions & 589 deletions
Large diffs are not rendered by default.

python/lib/sift_client/_tests/resources/test_test_results.py

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ def compare_test_report_fields(simulated: TestReport, actual: TestReport) -> Non
3939
assert simulated.system_operator == actual.system_operator
4040
assert simulated.start_time == actual.start_time
4141
assert simulated.end_time == actual.end_time
42+
assert simulated.metadata == actual.metadata
4243

4344

4445
def compare_test_step_fields(simulated: TestStep, actual: TestStep) -> None:
@@ -667,21 +668,13 @@ def test_replay_log_file_round_trip(self, sift_client, nostromo_run, tmp_path):
667668

668669
# Report: updates should have been folded in before create
669670
compare_test_report_fields(replay_result.report, direct["report"])
670-
assert replay_result.report.status == TestStatus.FAILED
671671

672672
# Steps (matched by name)
673673
replayed_steps_by_name = {s.name: s for s in replay_result.steps}
674674
for direct_step in direct["steps"].values():
675675
replayed_step = replayed_steps_by_name[direct_step.name]
676676
compare_test_step_fields(replayed_step, direct_step)
677677

678-
assert replayed_steps_by_name["RT Step 2"].status == TestStatus.FAILED
679-
680-
# Nested step parent should point to the replayed step1
681-
assert replayed_steps_by_name["RT Step 1.1"].parent_step_id == (
682-
replayed_steps_by_name["RT Step 1"].id_
683-
)
684-
685678
# Measurements (matched by name)
686679
replayed_measurements_by_name = {m.name: m for m in replay_result.measurements}
687680
for direct_m in direct["measurements"].values():

python/lib/sift_client/_tests/util/test_test_results_utils.py

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import time
12
from datetime import datetime, timezone
23

34
import numpy as np
@@ -16,11 +17,46 @@
1617
assign_value_to_measurement,
1718
evaluate_measurement_bounds,
1819
)
19-
from sift_client.util.test_results.context_manager import NewStep
20+
from sift_client.util.test_results.context_manager import NewStep, ReportContext
2021

2122
pytestmark = pytest.mark.integration
2223

2324

25+
class TestLogReplay:
26+
"""Test that the incremental replay subprocess creates real API objects."""
27+
28+
def test_replay_creates_report(self, sift_client):
29+
unique_name = f"replay-test-{datetime.now(timezone.utc).isoformat()}"
30+
31+
with ReportContext(
32+
sift_client,
33+
name=unique_name,
34+
test_case="test_replay_creates_report",
35+
log_file=True,
36+
) as rc:
37+
with rc.new_step(name="Step A") as step_a:
38+
with step_a.substep(name="Step A.1"):
39+
pass
40+
with rc.new_step(name="Step B"):
41+
pass
42+
43+
# Wait to ensure the report creation has completed.
44+
time.sleep(2)
45+
46+
reports = sift_client.test_results.list_(name=unique_name)
47+
assert len(reports) >= 1, f"Expected report '{unique_name}' to be created by replay"
48+
replay_report = reports[0]
49+
assert replay_report.name == unique_name
50+
51+
steps = sift_client.test_results.list_steps(test_reports=[replay_report])
52+
step_names = {s.name for s in steps}
53+
assert "Step A" in step_names
54+
assert "Step A.1" in step_names
55+
assert "Step B" in step_names
56+
57+
sift_client.test_results.delete(test_report=replay_report)
58+
59+
2460
class TestContextManager:
2561
def test_link_run_to_report(self, report_context, nostromo_run):
2662
report_context.report.update({"run_id": nostromo_run.id_})

python/lib/sift_client/resources/sync_stubs/__init__.pyi

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1963,15 +1963,20 @@ class TestResultsAPI:
19631963
"""
19641964
...
19651965

1966-
def replay_log_file(self, log_file: str | Path) -> ReplayResult:
1967-
"""Replay a log file by parsing each entry, simulating the results, then creating for real.
1966+
def replay_log_file(self, log_file: str | Path, *, incremental: bool = False) -> ReplayResult:
1967+
"""Replay a log file, creating real API objects from the logged simulation data.
19681968
1969-
This method reads a log file created by the simulation logging, reconstructs
1970-
all the objects via simulation, and then creates them via the actual API.
1971-
IDs are mapped from simulated to real during the creation process.
1969+
Two modes are available:
1970+
1971+
* **batch** (default): Parse the entire log, reconstruct objects via
1972+
simulation, then create them all via the API in one pass.
1973+
* **incremental**: Walk the log line-by-line, issuing the real API call
1974+
for each entry. The ``LogTracking`` header is updated after every
1975+
successful call so a subsequent invocation picks up where it left off.
19721976
19731977
Args:
19741978
log_file: Path to the log file to replay.
1979+
incremental: If True, use incremental mode.
19751980
19761981
Returns:
19771982
A ReplayResult containing the created report, steps, and measurements.

python/lib/sift_client/resources/test_results.py

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -619,20 +619,27 @@ async def delete_measurement(self, *, test_measurement: str | TestMeasurement) -
619619
async def replay_log_file(
620620
self,
621621
log_file: str | Path,
622+
*,
623+
incremental: bool = False,
622624
) -> ReplayResult:
623-
"""Replay a log file by parsing each entry, simulating the results, then creating for real.
625+
"""Replay a log file, creating real API objects from the logged simulation data.
626+
627+
Two modes are available:
624628
625-
This method reads a log file created by the simulation logging, reconstructs
626-
all the objects via simulation, and then creates them via the actual API.
627-
IDs are mapped from simulated to real during the creation process.
629+
* **batch** (default): Parse the entire log, reconstruct objects via
630+
simulation, then create them all via the API in one pass.
631+
* **incremental**: Walk the log line-by-line, issuing the real API call
632+
for each entry. The ``LogTracking`` header is updated after every
633+
successful call so a subsequent invocation picks up where it left off.
628634
629635
Args:
630636
log_file: Path to the log file to replay.
637+
incremental: If True, use incremental mode.
631638
632639
Returns:
633640
A ReplayResult containing the created report, steps, and measurements.
634641
"""
635-
result = await self._low_level_client.replay_log_file(log_file)
642+
result = await self._low_level_client.replay_log_file(log_file, incremental=incremental)
636643
result.report = self._apply_client_to_instance(result.report)
637644
result.steps = self._apply_client_to_instances(result.steps)
638645
result.measurements = self._apply_client_to_instances(result.measurements)

python/lib/sift_client/scripts/replay_test_result_log.py

Lines changed: 52 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,38 @@
33
from __future__ import annotations
44

55
import argparse
6+
import logging
67
import os
8+
import select
9+
import sys
10+
import tempfile
711

812
from sift_client import SiftClient, SiftConnectionConfig
913

14+
logger = logging.getLogger(__name__)
15+
16+
17+
def _print_result(result) -> None:
18+
print(f"Report: {result.report.name} (id={result.report.id_})")
19+
print(f"Steps: {len(result.steps)}")
20+
for step in result.steps:
21+
print(f" - {step.step_path} [{step.status}]")
22+
print(f"Measurements: {len(result.measurements)}")
23+
for m in result.measurements:
24+
print(f" - {m.name}: passed={m.passed}")
25+
1026

1127
def main() -> None:
1228
"""Replay a test result simulation log file against the Sift API."""
1329
parser = argparse.ArgumentParser(
1430
description="Replay a test result simulation log file against the Sift API.",
1531
)
1632
parser.add_argument("log_file", help="Path to the .jsonl log file to replay.")
33+
parser.add_argument(
34+
"--incremental",
35+
action="store_true",
36+
help="Replay line-by-line, tracking progress so reruns pick up where they left off.",
37+
)
1738
parser.add_argument("--grpc-url", default=os.getenv("SIFT_GRPC_URI", "localhost:50051"))
1839
parser.add_argument("--rest-url", default=os.getenv("SIFT_REST_URI", "localhost:8080"))
1940
parser.add_argument("--api-key", default=os.getenv("SIFT_API_KEY", ""))
@@ -30,15 +51,38 @@ def main() -> None:
3051
)
3152
)
3253

33-
result = client.test_results.replay_log_file(args.log_file)
54+
try:
55+
if args.incremental:
56+
result = _incremental_loop(client, args.log_file)
57+
else:
58+
result = client.test_results.replay_log_file(args.log_file)
59+
fp = os.path.abspath(args.log_file)
60+
if fp.startswith(tempfile.gettempdir()):
61+
os.remove(fp)
62+
if result:
63+
_print_result(result)
64+
except Exception as e:
65+
logger.error(e)
66+
logger.error(
67+
f"Error replaying log file: {args.log_file}.\n"
68+
f" Can replay with `replay-test-result-log {args.log_file}`."
69+
)
70+
raise
3471

35-
print(f"Report: {result.report.name} (id={result.report.id_})")
36-
print(f"Steps: {len(result.steps)}")
37-
for step in result.steps:
38-
print(f" - {step.step_path} [{step.status}]")
39-
print(f"Measurements: {len(result.measurements)}")
40-
for m in result.measurements:
41-
print(f" - {m.name}: passed={m.passed}")
72+
73+
def _incremental_loop(client: SiftClient, log_file: str):
74+
"""Replay incrementally in a loop until stdin is closed (EOF)."""
75+
result = None
76+
while True:
77+
received_signal, _, _ = select.select([sys.stdin], [], [], 1.0)
78+
result = client.test_results.replay_log_file(log_file, incremental=True)
79+
if received_signal:
80+
break
81+
logger.info(f"Replay completed: {result}")
82+
fp = os.path.abspath(log_file)
83+
if fp.startswith(tempfile.gettempdir()):
84+
os.remove(fp)
85+
return result
4286

4387

4488
if __name__ == "__main__":

python/lib/sift_client/transport/grpc_transport.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import asyncio
1010
import atexit
1111
import logging
12+
import os
1213
import threading
1314
from typing import Any
1415
from urllib.parse import urlparse
@@ -98,6 +99,8 @@ def __init__(self, config: GrpcConfig):
9899
Args:
99100
config: The gRPC client configuration.
100101
"""
102+
os.environ.setdefault("GRPC_ENABLE_FORK_SUPPORT", "0")
103+
101104
self._config = config
102105
# map each asyncio loop to its async channel and stub dict
103106
self._channels_async: dict[asyncio.AbstractEventLoop, Any] = {}

python/lib/sift_client/util/test_results/__init__.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,9 @@ def main(self):
5858
- If you want each module(file) to be marked as a step w/ each test as a substep, import the `module_substep` fixture as well.
5959
- The `report_context` fixture requires a fixture `sift_client` returning an `SiftClient` instance to be passed in.
6060
61+
62+
Note: FedRAMP users: report_context will log test results to a temp file to avoid API calls during test execution. If this is a shared environment, you should import the `report_context_no_logging` fixture instead.
63+
6164
###### Example at top of your test file or in your conftest.py file:
6265
6366
```python
@@ -102,6 +105,7 @@ def test_example(report_context, step):
102105
pytest_runtest_makereport,
103106
report_context,
104107
report_context_check_connection,
108+
report_context_no_logging,
105109
step,
106110
step_check_connection,
107111
)
@@ -115,6 +119,7 @@ def test_example(report_context, step):
115119
"pytest_runtest_makereport",
116120
"report_context",
117121
"report_context_check_connection",
122+
"report_context_no_logging",
118123
"step",
119124
"step_check_connection",
120125
]

python/lib/sift_client/util/test_results/context_manager.py

Lines changed: 29 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import logging
55
import os
66
import socket
7+
import subprocess
78
import tempfile
89
import traceback
910
from contextlib import AbstractContextManager
@@ -100,7 +101,28 @@ def __init__(
100101
)
101102
self.report = client.test_results.create(create, log_file=self.log_file)
102103

104+
def _open_replay_proc(self):
105+
if self.log_file is not None:
106+
# To avoid GRPC forking errors, temporarily redirect stderr at the fd level before forking, so the child inherits /dev/null on fd 2 when the atfork handler fires.
107+
saved_stderr = os.dup(2)
108+
devnull_fd = os.open(os.devnull, os.O_WRONLY)
109+
os.dup2(devnull_fd, 2)
110+
os.close(devnull_fd)
111+
try:
112+
self._replay_proc = subprocess.Popen(
113+
["replay-test-result-log", "--incremental", str(self.log_file)],
114+
stdin=subprocess.PIPE,
115+
stdout=subprocess.DEVNULL,
116+
stderr=subprocess.DEVNULL,
117+
)
118+
finally:
119+
os.dup2(saved_stderr, 2)
120+
os.close(saved_stderr)
121+
else:
122+
self._replay_proc = None
123+
103124
def __enter__(self):
125+
self._open_replay_proc()
104126
return self
105127

106128
def __exit__(self, exc_type, exc_value, traceback):
@@ -112,20 +134,14 @@ def __exit__(self, exc_type, exc_value, traceback):
112134
else:
113135
update["status"] = TestStatus.PASSED
114136
self.report.update(update, log_file=self.log_file)
115-
if self.log_file:
137+
138+
if self._replay_proc is not None:
116139
try:
117-
# Try replaying the log file and clean up the file if it's a temporary file.
118-
self.client.test_results.replay_log_file(self.log_file)
119-
fp = os.path.abspath(self.log_file)
120-
tmp_dir = tempfile.gettempdir()
121-
if fp.startswith(tmp_dir):
122-
os.remove(fp)
123-
except Exception as e:
124-
logger.error(e)
125-
logger.error(
126-
f"Error replaying log file: {self.log_file}.\n Can replay with `replay-test-result-log {self.log_file}`."
127-
)
128-
raise e
140+
self._replay_proc.communicate(timeout=10)
141+
except subprocess.TimeoutExpired:
142+
logger.warning("Replay process did not exit in 10s, killing it")
143+
self._replay_proc.kill()
144+
self._replay_proc.wait()
129145

130146
return True
131147

python/lib/sift_client/util/test_results/pytest_util.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,14 @@ def report_context(
6363
yield from _report_context_impl(sift_client, request)
6464

6565

66+
@pytest.fixture(scope="session", autouse=True)
67+
def report_context_no_logging(
68+
sift_client: SiftClient, request: pytest.FixtureRequest
69+
) -> Generator[ReportContext | None, None, None]:
70+
"""Create a report context for the session without log file."""
71+
yield from _report_context_impl(sift_client, request, log_file=None)
72+
73+
6674
def _step_impl(
6775
report_context: ReportContext, request: pytest.FixtureRequest
6876
) -> Generator[NewStep | None, None, None]:

0 commit comments

Comments
 (0)