Skip to content

Commit c745541

Browse files
Python(feat): clean up the abort propagation and output location settings (#642)
1 parent 30c5de6 commit c745541

21 files changed

Lines changed: 705 additions & 385 deletions

python/docs/guides/pytest_plugin/configuration.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ Prefer real environment variables (shell exports, CI secrets) for anything you
6767
can't keep in a local file.
6868
6969
!!! warning "FedRAMP / shared environments"
70-
Pass `--sift-log-file=false` (or set the ini key to `"false"`) to skip the
70+
Pass `--no-sift-log-file` (or set `sift_log_file = false`) to skip the
7171
temp file + worker pipeline. Create/update calls then run inline against the
7272
API instead of being deferred through a subprocess.
7373
@@ -146,8 +146,9 @@ suggestion, so typos like `SIFT_REPORT_SERIALNUM` surface immediately.
146146
147147
| Setting | CLI flag | Ini (`[tool.pytest.ini_options]`) |
148148
|---|---|---|
149-
| Path to the JSONL log of create/update calls (path \| true \| false \| none). | `--sift-log-file` | `sift_log_file` |
150-
| DEBUG-level audit trace of plugin behavior (path \| true \| false). On by default to a temp file, with warnings echoed to stdout; set a path to pin the file, or false to disable. | `--sift-audit-log` | `sift_audit_log` |
149+
| Directory for this run's artifacts (JSONL log, audit trace). Each run gets its own random subfolder. Defaults to a temp directory. | `--sift-output-dir` | `sift_output_dir` |
150+
| Write the JSONL log of create/update calls. On by default; --no-sift-log-file disables it (incompatible with --sift-offline). | `--no-sift-log-file` | `sift_log_file` |
151+
| Write the DEBUG audit trace of plugin behavior, with warnings echoed to stdout. On by default; --no-sift-audit-log disables it. | `--no-sift-audit-log` | `sift_audit_log` |
151152
| Capture git repo/branch/commit on the report. | `--no-sift-git-metadata` | `sift_git_metadata` |
152153
| Skip the session-start ping; route create/update through the JSONL log. | `--sift-offline` | `sift_offline` |
153154
| Disable Sift entirely (no API calls, no log file). Supersedes --sift-offline. | `--sift-disabled` | `sift_disabled` |

python/docs/guides/pytest_plugin/index.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,14 @@ A `TestReport` shows up in Sift once the session finishes.
6666
it does not short-circuit on the first failure and skip every measurement
6767
after it.
6868

69+
!!! tip "Stopping the whole run early"
70+
`pytest.fail()` fails a single test. To stop the session,
71+
`pytest.exit("...")` ends it and rolls the report up as `FAILED`, while
72+
`sift_client.pytest_plugin.abort("...")` ends it and rolls the report up as
73+
`ABORTED`, for a system-level stop where the run was cut off rather than a
74+
test failing (a real Ctrl-C does the same). See
75+
[Pass/Fail Behavior](pass_fail_behavior.md#stopping-a-run-as-aborted).
76+
6977
## Sensible defaults
7078

7179
With nothing but the `conftest.py` above, you get:

python/docs/guides/pytest_plugin/pass_fail_behavior.md

Lines changed: 69 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -34,48 +34,72 @@ mapping to `FAILED`. A non-assertion exception gets its formatted traceback
3434

3535
## Hard exits
3636

37-
Hard exits map to `ABORTED`. The step is resolved during fixture teardown, not
38-
at the instant of the exit:
39-
40-
- When the exit produces a call-phase report (`sys.exit(1)`, `SystemExit`), the
41-
plugin reads the status off that report.
42-
- When a `KeyboardInterrupt` aborts the session before any call-phase report
43-
(Ctrl-C, or `raise KeyboardInterrupt` in the body), pytest still runs fixture
44-
finalizers as it unwinds. The plugin sees setup completed with no call outcome
45-
and resolves the cut-off step to `ABORTED` there.
46-
47-
The status only reaches the report if those finalizers run. If the process is
48-
killed before they do (`SIGKILL`, the OOM killer, power loss), nothing is written
49-
and the step keeps the `IN_PROGRESS` it was created with. That is the only path
50-
that leaves a step `IN_PROGRESS` in a finalized report.
51-
52-
| Scenario | Trigger | Outcome |
53-
| ---------------------------------------------- | ---------------------------------- | ------------------------------------------------ |
54-
| `SystemExit` from the test body | `sys.exit(1)` | `ABORTED` (read from the call-phase report) |
55-
| `KeyboardInterrupt` from the test body | `raise KeyboardInterrupt` | `ABORTED` (resolved during teardown) |
56-
| Session-aborting `KeyboardInterrupt` | Ctrl-C terminates pytest | `ABORTED` (resolved during teardown) |
57-
| Process killed before finalizers run | `SIGKILL` / OOM / power loss | `IN_PROGRESS` (nothing written after creation) |
58-
59-
### Abort propagation through nested substeps
60-
61-
Every step that was open when the abort fired records
62-
`ABORTED`.
37+
A hard exit resolves the cut-off step to `ABORTED`, recorded while pytest runs
38+
fixture finalizers on the way out. What the containers (class, module, package)
39+
and the report resolve to depends on why the run stopped:
40+
41+
- A **failure stop** rolls up `FAILED`. `pytest.exit()`, `sys.exit()` /
42+
`SystemExit`, an `assert`, or an exception means the test ended the run on a
43+
fault, so the exited step is `ABORTED` (or `FAILED`/`ERROR` for an
44+
assert/exception) while its containers and the report read `FAILED`.
45+
- A **system stop** rolls up `ABORTED`. Ctrl-C / `KeyboardInterrupt`, or the
46+
`abort()` helper below, means the run was cut off rather than a test failing,
47+
so the exited step, its containers, and the report all read `ABORTED`.
48+
49+
`SystemExit` is read from the call-phase report; the session-stopping exits abort
50+
before that report fires, so the step resolves during teardown instead. If the
51+
process dies before finalizers run (`SIGKILL`, OOM, power loss) nothing more is
52+
written and the step stays `IN_PROGRESS`, the only path that leaves a step
53+
`IN_PROGRESS` in a finished report.
54+
55+
| Trigger | Exited step | Containers + report |
56+
| -------------------------------- | ------------------ | ------------------- |
57+
| `assert` / exception | `FAILED` / `ERROR` | `FAILED` |
58+
| `sys.exit()` / `SystemExit` | `ABORTED` | `FAILED` |
59+
| `pytest.exit("...")` | `ABORTED` | `FAILED` |
60+
| `abort("...")` (Sift) | `ABORTED` | `ABORTED` |
61+
| Ctrl-C / `KeyboardInterrupt` | `ABORTED` | `ABORTED` |
62+
| process killed (`SIGKILL`/OOM) | `IN_PROGRESS` | `IN_PROGRESS` |
63+
64+
Within a stop, `ABORTED` is recorded on each step the exit unwinds through: the
65+
open substeps and the test step. A substep that closed before the exit keeps its
66+
own status.
6367

6468
```python title="test_abort.py"
6569
import sys
6670

6771

6872
def test_x(step):
6973
with step.substep(name="completed_sub"):
70-
pass # closes as PASSED before the abort
74+
pass # closed PASSED before the abort
7175
with step.substep(name="outer_sub") as outer_sub:
7276
with outer_sub.substep(name="inner_sub"):
73-
sys.exit(1) # ABORTED applied to inner_sub, outer_sub, and the test step
77+
sys.exit(1)
78+
```
79+
80+
`completed_sub` stays `PASSED`; `inner_sub`, `outer_sub`, and the test step are
81+
`ABORTED`. The enclosing module reads `FAILED`, since `sys.exit()` is a failure
82+
stop.
83+
84+
### Stopping a run as aborted
85+
86+
`sift_client.pytest_plugin.abort(reason)` stops the session and records the
87+
report and the open parent steps as `ABORTED` rather than `FAILED`. Use it for a
88+
system-level stop where the run was cut off rather than a test failing, such as
89+
the device under test losing power. A real Ctrl-C does the same automatically.
90+
91+
```python
92+
from sift_client.pytest_plugin import abort
93+
94+
95+
def test_flash(step):
96+
if not device_responding():
97+
abort("device under test is not responding")
98+
...
7499
```
75100

76-
The Sift report shows `completed_sub` as `PASSED` and the three steps
77-
still open at the abort (`inner_sub`, `outer_sub`, and the test step
78-
itself) as `ABORTED`.
101+
For a stop that should read as a failure, use `pytest.exit()`; for a single
102+
failing test, use `pytest.fail()`.
79103

80104
## Skips
81105

@@ -180,14 +204,20 @@ Every non-`PASSED`/`SKIPPED` step marks its parent as failed. What the
180204
parent records depends on whether its own scope had an abort and whether
181205
a child already failed:
182206

183-
- A hard exit (`SystemExit` or an observed `KeyboardInterrupt`) in the
184-
step's own scope records `ABORTED`. `ABORTED` propagates through every
185-
step the abort passes through on its way up.
186-
- A child that already recorded a non-`PASSED`/`SKIPPED` outcome marks
187-
the parent as `FAILED`. This holds whether or not an exception is still
188-
propagating through the parent's scope: only the originating substep
189-
records `ERROR`; ancestors inherit `FAILED`. The traceback stays on
190-
the originating step's `error_info`.
207+
- A hard exit (`SystemExit` or an observed `KeyboardInterrupt`) records
208+
`ABORTED` on the step in whose scope it fired, and `ABORTED` propagates
209+
through every step the exception unwinds through on its way up: the
210+
open substeps and the test step. Container parents (class, module,
211+
package) are closed out-of-band rather than by the unwinding exception,
212+
so they are not on that path. On a failure stop (`pytest.exit()`,
213+
`sys.exit`) they inherit `FAILED` like any other non-pass child; on a
214+
system stop (Ctrl-C / `KeyboardInterrupt`, or `abort()`) the run is flagged
215+
aborted and they resolve `ABORTED` instead. See [Hard exits](#hard-exits).
216+
- A child that recorded a non-`PASSED`/`SKIPPED` outcome marks the parent
217+
as `FAILED`. This holds whether or not an exception is still propagating
218+
through the parent's scope: only the originating step records `ERROR` (or
219+
`ABORTED`); ancestors that inherit the result take `FAILED`. The
220+
traceback stays on the originating step's `error_info`.
191221
- A step records `ERROR` only when its own scope raised a non-Assertion
192222
exception AND no child has failed.
193223

python/lib/sift_client/_internal/pytest_plugin/audit_log.py

Lines changed: 14 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,9 @@
22
33
On by default: every session attaches two handlers to the ``sift_client`` root
44
logger so plugin-behavior modules AND high-value SDK call sites land in one file
5-
(a temp file unless ``--sift-audit-log=<path>`` pins one), with warnings also
6-
echoed to stdout. Pass ``--sift-audit-log=false`` (or set ``sift_audit_log =
7-
"false"``) to turn it off. The replay subprocess gets its own sibling file via
5+
(in the run's ``--sift-output-dir``, or a temp dir), with warnings also echoed
6+
to stdout. Pass ``--no-sift-audit-log`` (or set ``sift_audit_log = false``) to
7+
turn it off. The replay subprocess gets its own sibling file via
88
``replay_audit_path``.
99
1010
Handlers are removed at session end (``pytest_unconfigure`` ->
@@ -110,41 +110,17 @@ def replay_audit_path(main_path: Path) -> Path:
110110
return main_path.with_suffix(".replay" + main_path.suffix)
111111

112112

113-
def audit_disabled(value: object) -> bool:
114-
"""Whether audit logging is explicitly turned off.
113+
def _make_session_dir(base: Path | None = None) -> Path:
114+
"""Create and return ``<base>/<random>/`` for this run's artifacts.
115115
116-
Default on: only ``False`` / ``"false"`` / ``"none"`` disables. Anything
117-
else — unset, ``"true"``, or a path — leaves it enabled.
116+
``base`` is the ``--sift-output-dir`` directory when set, else
117+
``<tmpdir>/sift_test_results``. Each run gets its own random subfolder (from
118+
``tempfile.mkdtemp``) so repeated or concurrent runs never collide, and all
119+
of a run's artifacts (JSONL log, tracking sidecar, audit log, replay audit
120+
log) land inside it together.
118121
"""
119-
if value is False:
120-
return True
121-
return isinstance(value, str) and value.strip().lower() in ("false", "none")
122-
123-
124-
def explicit_audit_path(value: object) -> Path | None:
125-
"""The file path the user pinned, or ``None`` to use a temp default.
126-
127-
``"true"`` / ``"1"`` / unset all mean "enabled, no specific path", so the
128-
caller falls back to :func:`default_audit_path`.
129-
"""
130-
if not isinstance(value, str):
131-
return None
132-
text = value.strip()
133-
if text.lower() in ("", "true", "1", "false", "none"):
134-
return None
135-
return Path(text)
136-
137-
138-
def _make_session_dir() -> Path:
139-
"""Create and return ``<tmpdir>/sift_test_results/<random>/``.
140-
141-
All per-session temp artifacts (JSONL log, tracking sidecar, audit log,
142-
replay audit log) land inside this directory so they're easy to locate and
143-
clean up together. The random component comes from ``tempfile.mkdtemp`` —
144-
the same OS-backed source used by ``NamedTemporaryFile``.
145-
"""
146-
parent = Path(tempfile.gettempdir()) / "sift_test_results"
147-
parent.mkdir(exist_ok=True)
122+
parent = base if base is not None else Path(tempfile.gettempdir()) / "sift_test_results"
123+
parent.mkdir(parents=True, exist_ok=True)
148124
return Path(tempfile.mkdtemp(dir=parent, prefix=""))
149125

150126

@@ -198,10 +174,9 @@ def configure_audit_logging(
198174
"""
199175
from sift_client._internal.pytest_plugin.options import AUDIT_LOG_OPTION
200176

201-
raw = AUDIT_LOG_OPTION.resolve(config)
202-
if audit_disabled(raw):
177+
if not AUDIT_LOG_OPTION.resolve(config):
203178
return None
204-
path = explicit_audit_path(raw) or default_audit_path(session_dir=session_dir)
179+
path = default_audit_path(session_dir=session_dir)
205180
attach_file_handler(path)
206181
logger = logging.getLogger(ROOT_LOGGER)
207182
if not any(

python/lib/sift_client/_internal/pytest_plugin/options.py

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -217,22 +217,36 @@ def _walk_toml(data: dict[str, Any], path: tuple[str, ...]) -> Any:
217217
# loads .env for local dev; CI sets the same names from its secret store).
218218
# ---------------------------------------------------------------------------
219219

220-
# Pytest behavior. The CLI flag survives because the per-run override is real.
220+
# Pytest behavior. The CLI flags survive because the per-run override is real.
221+
OUTPUT_DIR_OPTION = Option(
222+
name="output_dir",
223+
category=CAT_BEHAVIOR,
224+
help="Directory for this run's artifacts (JSONL log, audit trace). Each run gets "
225+
"its own random subfolder. Defaults to a temp directory.",
226+
cli="--sift-output-dir",
227+
ini="sift_output_dir",
228+
)
221229
LOG_FILE_OPTION = Option(
222230
name="log_file",
223231
category=CAT_BEHAVIOR,
224-
help="Path to the JSONL log of create/update calls (path | true | false | none).",
225-
cli="--sift-log-file",
232+
help="Write the JSONL log of create/update calls. On by default; "
233+
"--no-sift-log-file disables it (incompatible with --sift-offline).",
234+
cli="--no-sift-log-file",
235+
cli_action="store_false",
226236
ini="sift_log_file",
237+
ini_type="bool",
238+
ini_default=True,
227239
)
228240
AUDIT_LOG_OPTION = Option(
229241
name="audit_log",
230242
category=CAT_BEHAVIOR,
231-
help="DEBUG-level audit trace of plugin behavior (path | true | false). On by "
232-
"default to a temp file, with warnings echoed to stdout; set a path to pin the "
233-
"file, or false to disable.",
234-
cli="--sift-audit-log",
243+
help="Write the DEBUG audit trace of plugin behavior, with warnings echoed to "
244+
"stdout. On by default; --no-sift-audit-log disables it.",
245+
cli="--no-sift-audit-log",
246+
cli_action="store_false",
235247
ini="sift_audit_log",
248+
ini_type="bool",
249+
ini_default=True,
236250
)
237251
GIT_METADATA_OPTION = Option(
238252
name="git_metadata",
@@ -406,6 +420,7 @@ def _walk_toml(data: dict[str, Any], path: tuple[str, ...]) -> Any:
406420
)
407421

408422
PLUGIN_OPTIONS: tuple[Option, ...] = (
423+
OUTPUT_DIR_OPTION,
409424
LOG_FILE_OPTION,
410425
AUDIT_LOG_OPTION,
411426
GIT_METADATA_OPTION,

python/lib/sift_client/_internal/pytest_plugin/report.py

Lines changed: 19 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@
5555
def resolve_real_report_id(context: Any) -> str | None:
5656
"""Resolve the real server-side report id for the online footer link.
5757
58-
In synchronous online mode (``--sift-log-file=false``) the report is created
58+
In synchronous online mode (``--no-sift-log-file``) the report is created
5959
directly against the API, so ``report.id_`` is already the real id. In the
6060
default incremental mode the report is created through the simulate path
6161
(a client-side UUID) and the background worker maps it to the real id on
@@ -377,41 +377,20 @@ def format_template(
377377
return fallback
378378

379379

380-
def resolve_log_file(pytestconfig: pytest.Config | None) -> str | Path | bool | None:
381-
"""Determine log_file value from CLI flag or ini key.
380+
def log_file_enabled(pytestconfig: pytest.Config | None) -> bool:
381+
"""Whether the JSONL log of create/update calls is written.
382382
383-
Three signal types arrive here:
384-
385-
* ``None``: unset; nothing was passed on the CLI and the ini key is
386-
absent. Treat as the default "use a temp file."
387-
* Python ``False``: an explicit disable, typically set in a conftest via
388-
``config.option.sift_log_file = False``. Return ``None`` so
389-
the rest of the pipeline knows to skip logging entirely.
390-
* A string (from CLI or ini): interpret ``"true"`` / ``"1"`` as the temp
391-
file default, ``"false"`` / ``"none"`` as disable, anything else as a
392-
file path.
393-
394-
Rejects ``--sift-log-file=none`` combined with ``--sift-offline`` since
395-
offline mode needs the log file as its sole sink.
383+
On by default; ``--no-sift-log-file`` disables it. Offline mode routes every
384+
create/update call through the log as its only sink, so disabling the log
385+
while offline is a usage error.
396386
"""
397-
raw = LOG_FILE_OPTION.resolve(pytestconfig)
398-
disabled = raw is False or (isinstance(raw, str) and raw.lower() in ("false", "none"))
399-
if disabled and is_offline(pytestconfig):
387+
enabled = bool(LOG_FILE_OPTION.resolve(pytestconfig))
388+
if not enabled and is_offline(pytestconfig):
400389
raise pytest.UsageError(
401-
"--sift-log-file=none is incompatible with --sift-offline; offline "
402-
"mode requires a log file. Pin one with --sift-log-file=<path>, or "
403-
"drop --sift-log-file=none to use a temp file."
390+
"--no-sift-log-file is incompatible with --sift-offline; offline mode "
391+
"requires the JSONL log as its only sink. Drop one of the two flags."
404392
)
405-
if raw is False:
406-
return None
407-
if not raw:
408-
return True
409-
lower = str(raw).lower()
410-
if lower in ("true", "1"):
411-
return True
412-
if lower in ("false", "none"):
413-
return None
414-
return Path(raw)
393+
return enabled
415394

416395

417396
def report_context_impl(
@@ -458,15 +437,16 @@ def report_context_impl(
458437
"pytest_command": command,
459438
}
460439
# Mode → ReportContext flags:
461-
# online (default): log_file=<temp or user path>, replay_log_file=True
462-
# --sift-offline: log_file=<temp or user path>, replay_log_file=False
463-
# --sift-disabled: log_file=False, replay_log_file=False
440+
# online (default): log_file=<path in session dir>, replay_log_file=True
441+
# --sift-offline: log_file=<path in session dir>, replay_log_file=False
442+
# --sift-disabled / --no-sift-log-file: log_file=False, replay_log_file=False
464443
disabled = sift_client._simulate
465444
offline = False if disabled else is_offline(pytestconfig)
466-
log_file: str | Path | bool | None = False if disabled else resolve_log_file(pytestconfig)
467-
# When the log would use a default temp file and the plugin already created
468-
# a session dir, pin the JSONL inside that dir so it lands alongside the
469-
# audit log rather than having ReportContext mint a separate session dir.
445+
log_file: str | Path | bool = False if disabled else log_file_enabled(pytestconfig)
446+
# Place the JSONL inside the run's session dir so it lands alongside the
447+
# audit log. pytest_configure created the dir whenever the log is enabled; if
448+
# one isn't present (e.g. ReportContext used outside pytest), log_file stays
449+
# True and ReportContext mints its own dir.
470450
if log_file is True and pytestconfig is not None:
471451
from sift_client.pytest_plugin import SIFT_SESSION_DIR_STASH_KEY
472452

0 commit comments

Comments
 (0)