Skip to content

Commit cc1bbdd

Browse files
committed
python(feat): Test results logging now happens in a subprocess while test runs.
1 parent b70b6e3 commit cc1bbdd

9 files changed

Lines changed: 595 additions & 262 deletions

File tree

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

Lines changed: 396 additions & 172 deletions
Large diffs are not rendered by default.

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

Lines changed: 11 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -697,24 +697,23 @@ def test_import_log_file_round_trip(self, sift_client, nostromo_run, tmp_path):
697697
compare_test_measurement_fields(replayed_m, direct_m)
698698

699699
@pytest.mark.asyncio
700-
async def test_malformed_log_line_raises(self, tmp_path):
701-
"""import_log_file raises ValueError on a line that doesn't match the expected format."""
700+
async def test_malformed_log_line_skipped(self, tmp_path):
701+
"""Malformed lines are skipped; a file with no valid entries raises 'No CreateTestReport'."""
702702
log_file = tmp_path / "bad.jsonl"
703-
log_file.write_text("this is not a valid log line\n")
703+
log_file.write_text(
704+
'[LogTracking] {"lastUploadedLine":0,"idMap":{}}\nthis is not a valid log line\n'
705+
)
704706

705707
client = TestResultsLowLevelClient(grpc_client=MagicMock())
706-
with pytest.raises(ValueError, match="malformed log line"):
708+
with pytest.raises(ValueError, match="Invalid log line: this is not a valid log lin"):
707709
await client.import_log_file(log_file)
708710

709711
@pytest.mark.asyncio
710-
async def test_malformed_line_after_valid_lines_raises(self, tmp_path):
711-
"""A malformed line after valid entries still raises."""
712-
log_file = tmp_path / "mixed.jsonl"
713-
log_file.write_text(
714-
'[CreateTestReport] {"name":"r","testCase":"c","testSystemName":"s"}\n'
715-
"totally broken line\n"
716-
)
712+
async def test_empty_log_file_raises(self, tmp_path):
713+
"""A log file with only a LogTracking header and no entries raises."""
714+
log_file = tmp_path / "empty.jsonl"
715+
log_file.write_text('[LogTracking] {"lastUploadedLine":0,"idMap":{}}\n')
717716

718717
client = TestResultsLowLevelClient(grpc_client=MagicMock())
719-
with pytest.raises(ValueError, match="malformed log line"):
718+
with pytest.raises(ValueError, match="No CreateTestReport found"):
720719
await client.import_log_file(log_file)
Lines changed: 9 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,43 +1,14 @@
1-
"""Override report_context to disable log file simulation for integration tests in this directory so that we can exercise the context manager when no log file is provided."""
2-
3-
from __future__ import annotations
4-
5-
from typing import TYPE_CHECKING, Generator
6-
71
import pytest
82

9-
from sift_client.util.test_results.pytest_util import _report_context_impl, _step_impl
10-
11-
if TYPE_CHECKING:
12-
from sift_client.client import SiftClient
13-
from sift_client.util.test_results.context_manager import NewStep, ReportContext
14-
15-
16-
@pytest.fixture(scope="session", autouse=True)
17-
def report_context(
18-
sift_client: SiftClient, client_has_connection: bool, request: pytest.FixtureRequest
19-
) -> Generator[ReportContext | None, None, None]:
20-
if client_has_connection:
21-
yield from _report_context_impl(sift_client, request, log_file=None)
22-
else:
23-
yield None
24-
253

26-
@pytest.fixture(autouse=True)
27-
def step(
28-
report_context: ReportContext, client_has_connection: bool, request: pytest.FixtureRequest
29-
) -> Generator[NewStep | None, None, None]:
30-
if client_has_connection:
31-
yield from _step_impl(report_context, request)
32-
else:
33-
yield None
4+
def pytest_addoption(parser: pytest.Parser) -> None:
5+
existing_options = [opt.names() for opt in parser._anonymous.options]
6+
# Flatten the list of lists into a single list of strings
7+
flat_options = [item for sublist in existing_options for item in sublist]
8+
if not any("--sift-test-results-log-file" in name for name in flat_options):
9+
parser.addoption("--sift-test-results-log-file", action="store_true", default="false")
3410

3511

36-
@pytest.fixture(scope="module", autouse=True)
37-
def module_substep(
38-
report_context: ReportContext, client_has_connection: bool, request: pytest.FixtureRequest
39-
) -> Generator[NewStep | None, None, None]:
40-
if client_has_connection:
41-
yield from _step_impl(report_context, request)
42-
else:
43-
yield None
12+
def pytest_configure(config: pytest.Config) -> None:
13+
"""Configure the pytest configuration to disable the Sift test results log file."""
14+
config.option.sift_test_results_log_file = False

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

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2001,15 +2001,16 @@ class TestResultsAPI:
20012001
"""
20022002
...
20032003

2004-
def import_log_file(self, log_file: str | Path) -> ReplayResult:
2004+
def import_log_file(self, log_file: str | Path, incremental: bool = False) -> ReplayResult:
20052005
"""Replay a log file by parsing each entry, simulating the results, then creating for real.
20062006
20072007
This method reads a log file created by the simulation logging, reconstructs
20082008
all the objects via simulation, and then creates them via the actual API.
20092009
IDs are mapped from simulated to real during the creation process.
20102010
20112011
Args:
2012-
log_file: Path to the log file to replay.
2012+
log_file: Path to the log file to import.
2013+
incremental: (internal tooling) If True, goes line by line and calls every event vs. reading the entire file at once and sending resultant test report.
20132014
20142015
Returns:
20152016
A ReplayResult containing the created report, steps, and measurements.

python/lib/sift_client/resources/test_results.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -619,6 +619,7 @@ async def delete_measurement(self, *, test_measurement: str | TestMeasurement) -
619619
async def import_log_file(
620620
self,
621621
log_file: str | Path,
622+
incremental: bool = False,
622623
) -> ReplayResult:
623624
"""Replay a log file by parsing each entry, simulating the results, then creating for real.
624625
@@ -627,12 +628,13 @@ async def import_log_file(
627628
IDs are mapped from simulated to real during the creation process.
628629
629630
Args:
630-
log_file: Path to the log file to replay.
631+
log_file: Path to the log file to import.
632+
incremental: (internal tooling) If True, goes line by line and calls every event vs. reading the entire file at once and sending resultant test report.
631633
632634
Returns:
633635
A ReplayResult containing the created report, steps, and measurements.
634636
"""
635-
result = await self._low_level_client.import_log_file(log_file)
637+
result = await self._low_level_client.import_log_file(log_file, incremental=incremental)
636638
result.report = self._apply_client_to_instance(result.report)
637639
result.steps = self._apply_client_to_instances(result.steps)
638640
result.measurements = self._apply_client_to_instances(result.measurements)

python/lib/sift_client/scripts/import_test_result_log.py

Lines changed: 57 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,45 @@
33
from __future__ import annotations
44

55
import argparse
6+
import logging
67
import os
8+
import select
9+
import sys
10+
import tempfile
11+
from typing import TYPE_CHECKING
712

813
from sift_client import SiftClient, SiftConnectionConfig
914

15+
if TYPE_CHECKING:
16+
from sift_client._internal.low_level_wrappers.test_results import ReplayResult
17+
18+
logger = logging.getLogger(__name__)
19+
20+
21+
def _print_result(result: ReplayResult) -> None:
22+
print(f"Report: {result.report.name} (id={result.report.id_})")
23+
print(f"Steps: {len(result.steps)}")
24+
for step in result.steps:
25+
print(f" - {step.step_path} [{step.status}]")
26+
print(f"Measurements: {len(result.measurements)}")
27+
for m in result.measurements:
28+
print(f" - {m.name}: passed={m.passed}")
29+
30+
31+
def _incremental_import_loop(client: SiftClient, log_file: str) -> ReplayResult | None:
32+
"""Replay incrementally in a loop until stdin is closed (EOF)."""
33+
result = None
34+
while True:
35+
received_signal, _, _ = select.select([sys.stdin], [], [], 1.0)
36+
result = client.test_results.import_log_file(log_file, incremental=True)
37+
if received_signal:
38+
break
39+
logger.info(f"Replay completed: {result}")
40+
fp = os.path.abspath(log_file)
41+
if fp.startswith(tempfile.gettempdir()):
42+
os.remove(fp)
43+
return result
44+
1045

1146
def main() -> None:
1247
"""Replay a test result simulation log file against the Sift API."""
@@ -17,6 +52,9 @@ def main() -> None:
1752
parser.add_argument("--grpc-url", default=os.getenv("SIFT_GRPC_URI"))
1853
parser.add_argument("--rest-url", default=os.getenv("SIFT_REST_URI"))
1954
parser.add_argument("--api-key", default=os.getenv("SIFT_API_KEY"))
55+
parser.add_argument(
56+
"--incremental", action="store_true", help="Import the log file incrementally."
57+
)
2058
args = parser.parse_args()
2159

2260
if not args.grpc_url or not args.rest_url or not args.api_key:
@@ -33,15 +71,26 @@ def main() -> None:
3371
)
3472
)
3573

36-
result = client.test_results.import_log_file(args.log_file)
74+
try:
75+
if args.incremental:
76+
result = _incremental_import_loop(client, args.log_file)
77+
else:
78+
result = client.test_results.import_log_file(args.log_file)
79+
fp = os.path.abspath(args.log_file)
80+
if fp.startswith(tempfile.gettempdir()):
81+
os.remove(fp)
82+
if result:
83+
_print_result(result)
84+
except Exception as e:
85+
logger.error(e)
86+
logger.error(
87+
f"Error replaying log file: {args.log_file}.\n"
88+
f" Can replay with `replay-test-result-log {args.log_file}`."
89+
)
90+
raise
3791

38-
print(f"Report: {result.report.name} (id={result.report.id_})")
39-
print(f"Steps: {len(result.steps)}")
40-
for step in result.steps:
41-
print(f" - {step.step_path} [{step.status}]")
42-
print(f"Measurements: {len(result.measurements)}")
43-
for m in result.measurements:
44-
print(f" - {m.name}: passed={m.passed}")
92+
if result:
93+
_print_result(result)
4594

4695

4796
if __name__ == "__main__":

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

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,12 @@ 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-
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.
61+
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 can disable logging by passing ``--sift-test-results-log-file=false``.
62+
63+
#### Configuration
64+
65+
- Git metadata: You can configure the test results by passing the `--sift-test-results-git-metadata` flag to pytest in your commandline, conftest.py file, or as an addopt in your pyproject.toml file (https://docs.pytest.org/en/stable/reference/customize.html#configuration).
66+
- Log file: You can configure the log file by passing the `--sift-test-results-log-file` flag to pytest in your commandline, conftest.py file, or as an addopt in your pyproject.toml file.
6267
6368
###### Example at top of your test file or in your conftest.py file:
6469
@@ -101,10 +106,10 @@ def test_example(report_context, step):
101106
client_has_connection,
102107
module_substep,
103108
module_substep_check_connection,
109+
pytest_addoption,
104110
pytest_runtest_makereport,
105111
report_context,
106112
report_context_check_connection,
107-
report_context_no_logging,
108113
step,
109114
step_check_connection,
110115
)
@@ -115,10 +120,10 @@ def test_example(report_context, step):
115120
"client_has_connection",
116121
"module_substep",
117122
"module_substep_check_connection",
123+
"pytest_addoption",
118124
"pytest_runtest_makereport",
119125
"report_context",
120126
"report_context_check_connection",
121-
"report_context_no_logging",
122127
"step",
123128
"step_check_connection",
124129
]

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

Lines changed: 55 additions & 12 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
@@ -37,6 +38,29 @@
3738
logger = logging.getLogger(__name__)
3839

3940

41+
def _git_metadata() -> dict[str, str] | None:
42+
"""Return git branch and commit hash, or None if not in a git repo."""
43+
try:
44+
branch = subprocess.check_output(
45+
["git", "rev-parse", "--abbrev-ref", "HEAD"],
46+
stderr=subprocess.DEVNULL,
47+
text=True,
48+
).strip()
49+
commit = subprocess.check_output(
50+
["git", "rev-parse", "--short", "HEAD"],
51+
stderr=subprocess.DEVNULL,
52+
text=True,
53+
).strip()
54+
repo = subprocess.check_output(
55+
["git", "remote", "get-url", "origin"],
56+
stderr=subprocess.DEVNULL,
57+
text=True,
58+
).strip()
59+
return {"git_repo": repo, "git_branch": branch, "git_commit": commit}
60+
except Exception:
61+
return None
62+
63+
4064
class ReportContext(AbstractContextManager):
4165
"""Context manager for a new TestReport. See usage example in __init__.py."""
4266

@@ -48,6 +72,7 @@ class ReportContext(AbstractContextManager):
4872
step_number_at_depth: dict[int, int]
4973
open_step_results: dict[str, bool]
5074
any_failures: bool
75+
_import_proc: subprocess.Popen | None = None
5176

5277
def __init__(
5378
self,
@@ -57,6 +82,7 @@ def __init__(
5782
system_operator: str | None = None,
5883
test_case: str | None = None,
5984
log_file: str | Path | bool | None = None,
85+
include_git_metadata: bool = False,
6086
):
6187
"""Initialize a new report context.
6288
@@ -68,6 +94,7 @@ def __init__(
6894
test_case: The name of the test case. Will default to the basename of the file containing the test if not provided.
6995
log_file: If True, create a temp log file. If a path, use that path.
7096
All create/update operations will be logged to this file.
97+
include_git_metadata: If True, include git metadata in the report.
7198
"""
7299
self.client = client
73100
self.step_is_open = False
@@ -97,10 +124,31 @@ def __init__(
97124
end_time=datetime.now(timezone.utc),
98125
status=TestStatus.IN_PROGRESS,
99126
system_operator=system_operator,
127+
metadata=_git_metadata() if include_git_metadata else None, # type: ignore
100128
)
101129
self.report = client.test_results.create(create, log_file=self.log_file)
102130

131+
def _open_import_proc(self):
132+
"""Open a subprocess to import the log file."""
133+
# 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.
134+
saved_stderr = os.dup(2)
135+
devnull_fd = os.open(os.devnull, os.O_WRONLY)
136+
os.dup2(devnull_fd, 2)
137+
os.close(devnull_fd)
138+
try:
139+
self._import_proc = subprocess.Popen(
140+
["import-test-result-log", "--incremental", str(self.log_file)],
141+
stdin=subprocess.PIPE,
142+
stdout=subprocess.DEVNULL,
143+
stderr=subprocess.DEVNULL,
144+
)
145+
finally:
146+
os.dup2(saved_stderr, 2)
147+
os.close(saved_stderr)
148+
103149
def __enter__(self):
150+
if self.log_file:
151+
self._open_import_proc()
104152
return self
105153

106154
def __exit__(self, exc_type, exc_value, traceback):
@@ -112,19 +160,14 @@ def __exit__(self, exc_type, exc_value, traceback):
112160
else:
113161
update["status"] = TestStatus.PASSED
114162
self.report.update(update, log_file=self.log_file)
115-
if self.log_file:
163+
164+
if self._import_proc is not None:
116165
try:
117-
# Try replaying the log file and clean up the file if it's a temporary file.
118-
self.client.test_results.import_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 `import-test-result-log {self.log_file}`."
127-
)
166+
self._import_proc.communicate(timeout=10)
167+
except subprocess.TimeoutExpired:
168+
logger.error("Import process did not exit in 10s, killing it")
169+
self._import_proc.kill()
170+
self._import_proc.wait()
128171
raise
129172

130173
return True

0 commit comments

Comments
 (0)