Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 40 additions & 12 deletions python/docs/guides/pytest_plugin/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,11 +131,13 @@ Each kind has a home chosen for a specific workflow:

- **Pytest behavior** lives in `[tool.pytest.ini_options]` (log/offline/disabled/git/`*_step`/autouse/parametrize). A CLI flag exists for the ones with a real ad-hoc override workflow.
- **Connection** comes from the environment first, falling back to the ini keys; the API key is env-only so secrets stay out of committed files.
- **Report content** takes static defaults from `[tool.sift.pytest.report]` and per-run dynamic values from `SIFT_REPORT_*` env vars (CI builds, hardware cycling, anything `.env`-driven; pytest-dotenv loads `.env` for local dev).
- **Report content** takes static defaults from `[tool.sift.pytest.report]` and per-run dynamic values from `SIFT_REPORT_*` env vars (CI builds, hardware cycling, anything `.env`-driven; pytest-dotenv loads `.env` for local dev). `archive_on_create` also accepts a CLI flag and an ini key.

Precedence within a setting runs env > CLI flag > ini key > TOML > built-in
default. No setting exposes both env and CLI, so the chain isn't ambiguous in
practice.
default. For a boolean, an explicit `false` is a value: it overrides a `true`
from a lower-precedence source. `archive_on_create` uses every surface, so a
shared `pyproject.toml` can archive dev runs while production sets
`SIFT_REPORT_ARCHIVE_ON_CREATE=false`.

The plugin scans `SIFT_*` env vars and `[tool.sift.pytest.*]` keys at session
start; anything outside these tables fires a warning with a closest-match
Expand Down Expand Up @@ -171,15 +173,16 @@ suggestion, so typos like `SIFT_REPORT_SERIALNUM` surface immediately.

### Report content

| Setting | TOML (`[tool.sift...]`) | Env var |
|---|---|---|
| Template for the report display name. Placeholders: {target}, {command}, {args}, {rootdir}, {timestamp}, {count}, {git_repo}, {git_branch}, {git_commit}. | `[tool.sift.pytest.report] name` | — |
| Template for the report's test_case field (same placeholders as report_name). | `[tool.sift.pytest.report] test_case` | — |
| Name of the test system / rig. Defaults to the host's name. | `[tool.sift.pytest.report] test_system_name` | `SIFT_REPORT_TEST_SYSTEM_NAME` |
| Operator running the test. Defaults to the OS user. | `[tool.sift.pytest.report] system_operator` | `SIFT_REPORT_SYSTEM_OPERATOR` |
| Serial number of the unit under test. | `[tool.sift.pytest.report] serial_number` | `SIFT_REPORT_SERIAL_NUMBER` |
| Part number of the unit under test. | `[tool.sift.pytest.report] part_number` | `SIFT_REPORT_PART_NUMBER` |
| Free-form report metadata, as a TOML table of scalar values. For dynamic per-run keys, override the sift_report_metadata fixture in conftest. | `[tool.sift.pytest.report.metadata]` (table) | — |
| Setting | CLI flag | Ini (`[tool.pytest.ini_options]`) | TOML (`[tool.sift...]`) | Env var |
|---|---|---|---|---|
| Template for the report display name. Placeholders: {target}, {command}, {args}, {rootdir}, {timestamp}, {count}, {git_repo}, {git_branch}, {git_commit}. | — | — | `[tool.sift.pytest.report] name` | — |
| Template for the report's test_case field (same placeholders as report_name). | — | — | `[tool.sift.pytest.report] test_case` | — |
| Name of the test system / rig. Defaults to the host's name. | — | — | `[tool.sift.pytest.report] test_system_name` | `SIFT_REPORT_TEST_SYSTEM_NAME` |
| Operator running the test. Defaults to the OS user. | — | — | `[tool.sift.pytest.report] system_operator` | `SIFT_REPORT_SYSTEM_OPERATOR` |
| Serial number of the unit under test. | — | — | `[tool.sift.pytest.report] serial_number` | `SIFT_REPORT_SERIAL_NUMBER` |
| Part number of the unit under test. | — | — | `[tool.sift.pytest.report] part_number` | `SIFT_REPORT_PART_NUMBER` |
| Archive the report right after creating it, so it drops out of the default Test Results views. An explicit false overrides a true from a lower-precedence source. | `--sift-archive-on-create` | `sift_archive_on_create` | `[tool.sift.pytest.report] archive_on_create` | `SIFT_REPORT_ARCHIVE_ON_CREATE` |
| Free-form report metadata, as a TOML table of scalar values. For dynamic per-run keys, override the sift_report_metadata fixture in conftest. | — | — | `[tool.sift.pytest.report.metadata]` (table) | — |

<!-- END settings-reference -->

Expand Down Expand Up @@ -243,6 +246,31 @@ SIFT_REPORT_SYSTEM_OPERATOR=$CI_ACTOR \
pytest tests/
```

### Archiving a run at creation

`archive_on_create` archives the report immediately after the plugin creates
it. Archived reports drop out of the default Test Results views. The plugin
creates the report, then archives it in a second call. If that call fails, the
plugin logs a warning and the test session continues with the report
unarchived.

```toml title="pyproject.toml"
[tool.sift.pytest.report]
archive_on_create = true
```

You can also pass `--sift-archive-on-create`, set `sift_archive_on_create`
under `[tool.pytest.ini_options]`, or set `SIFT_REPORT_ARCHIVE_ON_CREATE`.
Precedence is the environment variable, then the CLI flag, then the ini key,
then TOML. An explicit `false` overrides a `true` from a lower source, so
production can set `SIFT_REPORT_ARCHIVE_ON_CREATE=false` while the shared TOML
stays `true`.

The terminal summary prints `(archived)` next to the report link. Offline, the
same note is the line after the `import-test-result-log` command, so the
command line itself still runs when copied. Replay of that log
archives the uploaded report.

### `name` vs `test_case`

The two fields look similar but serve opposite purposes:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1338,6 +1338,11 @@ def record_created(simulated_id: str, real_id: str) -> None:
real_report = await self._create_report_from_simulated(state.report)
real_report_id = real_report._id_or_error
record_created(state.report._id_or_error, real_report_id)
# Create has no is_archived field, so the collapsed flag goes out as an update.
if state.report.is_archived:
archive_update = TestReportUpdate(is_archived=True)
archive_update.resource_id = real_report_id
real_report = await self.update_test_report(archive_update, existing=real_report)

real_steps: list[TestStep] = []
for sim_step_id in state.steps_order:
Expand Down
97 changes: 94 additions & 3 deletions python/lib/sift_client/_internal/pytest_plugin/options.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,9 @@ class Option:
toml: tuple[str, ...] | None = None
env: str | None = None
merge: bool = False
# False counts as set. An unset ini default does not, so a lower TOML true
# can still apply.
explicit_bool: bool = False
Comment thread
alexluck-sift marked this conversation as resolved.
Outdated
surfaces: tuple[str, ...] = ("env", "cli", "ini", "toml")

@property
Expand Down Expand Up @@ -132,8 +135,9 @@ def resolve(self, config: pytest.Config | None) -> Any:

The walk order is :attr:`surfaces`, which puts env before cli by default.
``getini`` returns the typed default for unset bool/list keys, so this
only returns ini values for booleans (always meaningful), non-empty
strings, and non-empty lists.
returns ini values for booleans, non-empty strings, and non-empty lists.
``explicit_bool`` is the exception: an ini bool counts only when the key
is set, so the registered default does not hide a lower value.
"""
return self.resolve_with_source(config)[0]

Expand All @@ -157,14 +161,20 @@ def _read_surface(self, surface: str, config: pytest.Config | None) -> Any:
if not self.env:
return None
env_value = os.getenv(self.env)
return env_value if env_value else None
if not env_value:
return None
if self.explicit_bool:
return _coerce_explicit_bool(env_value, source=self.env)
return env_value
if config is None:
return None
if surface == "cli":
return config.getoption(self.cli_dest, default=None) if self.cli else None
if surface == "ini":
if not self.ini:
return None
if self.explicit_bool and not _ini_explicitly_set(config, self.ini):
return None
try:
ini_value = config.getini(self.ini)
except (KeyError, ValueError):
Expand All @@ -177,6 +187,9 @@ def _read_surface(self, surface: str, config: pytest.Config | None) -> Any:
if not self.toml:
return None
toml_value = _walk_toml(tool_sift(config), self.toml)
if self.explicit_bool:
source = "tool.sift." + ".".join(self.toml)
return _coerce_explicit_bool(toml_value, source=source)
return toml_value if toml_value not in (None, "") else None

def resolve_merged(self, config: pytest.Config | None) -> dict[str, str | float | bool]:
Expand Down Expand Up @@ -208,6 +221,65 @@ def resolve_merged(self, config: pytest.Config | None) -> dict[str, str | float
return result


_BOOL_TRUE = frozenset({"1", "true", "t", "yes", "y", "on"})
_BOOL_FALSE = frozenset({"0", "false", "f", "no", "n", "off"})


def _parse_bool_token(raw: str) -> bool | None:
"""Parse a boolean token. ``None`` when ``raw`` is not a boolean word."""
token = raw.strip().lower()
if token in _BOOL_TRUE:
return True
if token in _BOOL_FALSE:
return False
return None


def _coerce_explicit_bool(value: Any, *, source: str) -> bool | None:
"""Coerce one surface's value to bool. Unset stays ``None``.

A string ``false`` is ``False``, not a truthy string. Anything that is not
a bool or a boolean word warns and counts as unset.
"""
if isinstance(value, bool):
return value
if value is None or value == "":
return None
if isinstance(value, str):
parsed = _parse_bool_token(value)
if parsed is not None:
return parsed
from sift_client.pytest_plugin import SiftPytestPluginWarning

log_event(
logger,
logging.WARNING,
"config.bool",
name=source,
value=repr(value),
)
warnings.warn(
f"Ignoring {source}={value!r}: expected true or false.",
SiftPytestPluginWarning,
stacklevel=2,
)
return None


def _ini_explicitly_set(config: pytest.Config, name: str) -> bool:
"""Whether ``name`` was set in the ini file or via ``-o``, not just defaulted."""
override = getattr(config, "_get_override_ini_value", None)
if override is not None and override(name) is not None:
return True
inicfg = getattr(config, "inicfg", None)
if inicfg is None:
return False
try:
return name in inicfg
except TypeError:
return False
Comment thread
alexluck-sift marked this conversation as resolved.
Outdated


def _walk_toml(data: dict[str, Any], path: tuple[str, ...]) -> Any:
"""Walk a parsed TOML tree along ``path``; return None on any missing key."""
cur: Any = data
Expand Down Expand Up @@ -445,6 +517,22 @@ def _walk_toml(data: dict[str, Any], path: tuple[str, ...]) -> Any:
env="SIFT_REPORT_PART_NUMBER",
toml=("pytest", "report", "part_number"),
)
# The ini default is false. explicit_bool keeps that default from hiding TOML.
ARCHIVE_ON_CREATE_OPTION = Option(
name="archive_on_create",
category=CAT_REPORT,
help="Archive the report right after creating it, so it drops out of the "
"default Test Results views. An explicit false overrides a true from a "
"lower-precedence source.",
cli="--sift-archive-on-create",
cli_action="store_true",
ini="sift_archive_on_create",
ini_type="bool",
ini_default=False,
env="SIFT_REPORT_ARCHIVE_ON_CREATE",
toml=("pytest", "report", "archive_on_create"),
explicit_bool=True,
)
METADATA_OPTION = Option(
name="metadata",
category=CAT_REPORT,
Expand Down Expand Up @@ -478,6 +566,7 @@ def _walk_toml(data: dict[str, Any], path: tuple[str, ...]) -> Any:
SYSTEM_OPERATOR_OPTION,
SERIAL_NUMBER_OPTION,
PART_NUMBER_OPTION,
ARCHIVE_ON_CREATE_OPTION,
METADATA_OPTION,
)

Expand Down Expand Up @@ -565,6 +654,8 @@ def _env_cell(opt: Option) -> str:
("Env var", _env_cell),
],
CAT_REPORT: [
("CLI flag", _cli_cell),
("Ini (`[tool.pytest.ini_options]`)", _ini_cell),
Comment thread
alexluck-sift marked this conversation as resolved.
Outdated
("TOML (`[tool.sift...]`)", _toml_cell),
("Env var", _env_cell),
],
Expand Down
3 changes: 3 additions & 0 deletions python/lib/sift_client/_internal/pytest_plugin/report.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
from sift_client._internal.pytest_plugin.audit_log import log_event
from sift_client._internal.pytest_plugin.modes import is_offline
from sift_client._internal.pytest_plugin.options import (
ARCHIVE_ON_CREATE_OPTION,
GIT_METADATA_OPTION,
LOG_FILE_OPTION,
METADATA_OPTION,
Expand Down Expand Up @@ -474,6 +475,7 @@ def report_context_impl(
replay_log_file=not (disabled or offline),
metadata=report_metadata,
audit_log=audit_log,
archive_on_create=bool(ARCHIVE_ON_CREATE_OPTION.resolve(pytestconfig)),
# pytest tears this session-scoped fixture down during the LAST item's
# teardown phase but reports that phase's outcome afterwards, so a
# teardown failure on the final test is unknown here. The plugin's
Expand All @@ -494,6 +496,7 @@ def report_context_impl(
serial=report.serial_number or "-",
part=report.part_number or "-",
metadata=meta_kv,
archived=report.is_archived,
)
# What actually happens with the JSONL log, not the raw setting: the
# effective path (temp or pinned), or "disabled", plus whether the
Expand Down
17 changes: 14 additions & 3 deletions python/lib/sift_client/_internal/pytest_plugin/terminal.py
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,13 @@ def write_disabled_summary(terminalreporter: Any) -> None:
terminalreporter.write_line("Sift disabled — no test report created.")


def archived_suffix(report: Any) -> str:
"""`` (archived)`` when the report is archived, else empty."""
if getattr(report, "is_archived", False):
return " (archived)"
return ""


def write_report_summary(
terminalreporter: Any,
context: Any,
Expand Down Expand Up @@ -203,27 +210,31 @@ def write_report_summary(
if log_file is not None:
sift_kv(terminalreporter, "Log file", str(log_file))

archived = archived_suffix(report)
Comment thread
alexluck-sift marked this conversation as resolved.
Outdated
if offline:
if log_file is not None:
terminalreporter.write_sep("-", "to upload to Sift")
# (archived) is its own line so copying the command still runs.
terminalreporter.write_line(f" >> import-test-result-log {log_file}", cyan=True)
if archived:
terminalreporter.write_line(archived)
else:
if not report_id:
# Incremental upload never mapped the report (the worker died before
# replaying the create), so there's no real report to link.
sift_kv(
terminalreporter,
"Report",
f"not uploaded — replay with: import-test-result-log {log_file}",
f"not uploaded — replay with: import-test-result-log {log_file}{archived}",
yellow=True,
)
elif report_url is not None:
sift_kv(terminalreporter, "Report", report_url, cyan=True)
sift_kv(terminalreporter, "Report", f"{report_url}{archived}", cyan=True)
else:
sift_kv(
terminalreporter,
"Report",
f"id {report_id} (set sift_app_url for a clickable link)",
f"id {report_id} (set sift_app_url for a clickable link){archived}",
)

if report_id and getattr(context, "replay_incomplete", False) and log_file is not None:
Expand Down
Loading
Loading