49f1cfcdb6
CI / benchmark-publish (pull_request) Has been skipped
CI / push-validation (pull_request) Successful in 44s
CI / helm (pull_request) Successful in 50s
CI / lint (pull_request) Successful in 1m15s
CI / build (pull_request) Successful in 1m6s
CI / benchmark-regression (pull_request) Failing after 1m22s
CI / quality (pull_request) Successful in 1m47s
CI / typecheck (pull_request) Successful in 1m51s
CI / security (pull_request) Successful in 1m52s
CI / e2e_tests (pull_request) Successful in 4m19s
CI / unit_tests (pull_request) Failing after 4m50s
CI / docker (pull_request) Has been skipped
CI / coverage (pull_request) Has been skipped
CI / integration_tests (pull_request) Failing after 3m28s
CI / status-check (pull_request) Failing after 3s
Implemented PassSuppressFormatter, a custom Behave formatter that buffers
per-scenario output and only flushes it to stdout when the scenario failed
or errored. Passing scenarios produce no output, keeping an all-passing
suite at ~5-10 lines (the _print_overall_summary block only).
Key design decisions:
- PassSuppressFormatter (in scripts/behave_pass_suppress_formatter.py)
inherits from behave's Formatter base class so it can be registered via
behave.formatter._registry.register_as(), which enforces issubclass(cls,
Formatter).
- _SUPPRESS_STATUSES = {'passed', 'skipped'} determines which terminal
statuses are silently discarded; all others (failed, undefined, etc.)
trigger a buffer flush so no failure is hidden.
- _make_runner() in run_behave_parallel.py registers the formatter and
sets it as the default format whenever config.format is None (no
explicit -f/--format flag). Coverage mode (BEHAVE_PARALLEL_COVERAGE=1)
bypasses the formatter and falls back to config.default_format so
slipcover can instrument a single process.
- The formatter lives in a separate file to keep both scripts under the
500-line limit. _install_behave_parallel() in noxfile.py copies both
files into the behave_parallel package. A try/except import handles
both the direct-script path (tests via importlib) and the installed-
package path (nox CI).
- Three new BDD scenarios in behave_parallel_log_filtering.feature cover:
(1) passing scenario -> no output, (2) failing scenario -> full output,
(3) mixed run -> only failing scenario visible. All 20 scenarios pass.
ISSUES CLOSED: #10987
126 lines
5.1 KiB
Python
126 lines
5.1 KiB
Python
"""PassSuppressFormatter — a custom Behave formatter that suppresses output
|
|
for passing and skipped scenarios, reducing CI log noise from ~100,000 lines
|
|
to ≤ 10 lines for an all-passing suite.
|
|
|
|
This module is embedded in the same directory as ``run_behave_parallel.py``
|
|
so that ``_install_behave_parallel()`` in ``noxfile.py`` can copy both files
|
|
into the temporary ``behave_parallel`` package.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import io
|
|
from typing import Any
|
|
|
|
from behave.formatter.base import (
|
|
Formatter as _BehaveFormatter, # type: ignore[import-untyped]
|
|
)
|
|
|
|
# Sentinel set of statuses whose output should be suppressed.
|
|
# Every status not in this set (e.g. "failed", "undefined") causes the
|
|
# buffered scenario output to be flushed to the real output stream.
|
|
_SUPPRESS_STATUSES: frozenset[str] = frozenset({"passed", "skipped"})
|
|
|
|
|
|
class PassSuppressFormatter(_BehaveFormatter):
|
|
"""Behave formatter that suppresses output for passing scenarios.
|
|
|
|
All per-scenario output is buffered in a :class:`io.StringIO`. When a
|
|
scenario ends (signalled by the next :meth:`scenario` call or by
|
|
:meth:`eof`):
|
|
|
|
* **Failed / errored scenario** — the buffer is flushed to the real
|
|
output stream so developers and CI tools see the full step-level
|
|
details.
|
|
* **Passed / skipped scenario** — the buffer is silently discarded.
|
|
|
|
The result for an all-passing suite is zero formatter output, leaving
|
|
only the ``_print_overall_summary`` block emitted by the runner as
|
|
visible output (~5-10 lines regardless of suite size).
|
|
|
|
Coverage mode (``BEHAVE_PARALLEL_COVERAGE=1``) bypasses this formatter
|
|
entirely via ``_make_runner()``, which falls back to behave's default
|
|
format so that slipcover can instrument a single sequential process
|
|
without output interference.
|
|
"""
|
|
|
|
name: str = "pass_suppress"
|
|
description: str = "Suppress passing scenario output; show failures fully"
|
|
|
|
def __init__(self, stream_opener: Any, config: Any) -> None:
|
|
super().__init__(stream_opener, config)
|
|
# Ensure the real output stream is open (opens it if stream_opener
|
|
# was constructed with only a filename, not a pre-opened stream).
|
|
self.stream = self.open()
|
|
|
|
# Per-scenario buffering state.
|
|
self._current_scenario: Any = None
|
|
self._scenario_buf: io.StringIO = io.StringIO()
|
|
|
|
# ------------------------------------------------------------------
|
|
# Internal helpers
|
|
# ------------------------------------------------------------------
|
|
|
|
def _finalize_previous_scenario(self) -> None:
|
|
"""Flush the scenario buffer to the real stream if scenario failed.
|
|
|
|
Called at the start of each new scenario and at end-of-feature so
|
|
the previous scenario's buffered output is either committed or
|
|
discarded based on the scenario's final status.
|
|
"""
|
|
if self._current_scenario is None:
|
|
return
|
|
status: Any = getattr(self._current_scenario, "status", None)
|
|
status_name: str = (
|
|
getattr(status, "name", str(status)) if status is not None else ""
|
|
)
|
|
if status_name not in _SUPPRESS_STATUSES:
|
|
output = self._scenario_buf.getvalue()
|
|
if output:
|
|
self.stream.write(output)
|
|
self.stream.flush()
|
|
# Reset the buffer regardless — the next scenario starts fresh.
|
|
self._scenario_buf = io.StringIO()
|
|
|
|
# ------------------------------------------------------------------
|
|
# Formatter interface (behave lifecycle hooks)
|
|
# ------------------------------------------------------------------
|
|
|
|
def uri(self, uri: str) -> None:
|
|
"""Called before each feature file; no output needed."""
|
|
|
|
def feature(self, feature: Any) -> None:
|
|
"""Called at feature start; no output needed."""
|
|
|
|
def background(self, background: Any) -> None:
|
|
"""Called when a feature background is declared; no output needed."""
|
|
|
|
def scenario(self, scenario: Any) -> None:
|
|
"""Start buffering output for *scenario*, finalising the previous one."""
|
|
self._finalize_previous_scenario()
|
|
self._current_scenario = scenario
|
|
# Write the scenario header into the new buffer.
|
|
self._scenario_buf.write(f"\n {scenario.keyword}: {scenario.name}\n")
|
|
|
|
def step(self, step: Any) -> None:
|
|
"""Step announced before execution; no output needed at this point."""
|
|
|
|
def match(self, match: Any) -> None:
|
|
"""Step matched; no output needed."""
|
|
|
|
def result(self, step: Any) -> None:
|
|
"""Record a step result in the per-scenario buffer."""
|
|
status: Any = getattr(step, "status", None)
|
|
status_name: str = (
|
|
getattr(status, "name", "unknown") if status is not None else "unknown"
|
|
)
|
|
self._scenario_buf.write(f" {step.keyword} {step.name} ... {status_name}\n")
|
|
error_msg: str | None = getattr(step, "error_message", None)
|
|
if error_msg:
|
|
self._scenario_buf.write(f"{error_msg}\n")
|
|
|
|
def eof(self) -> None:
|
|
"""End of feature file: finalise the current (last) scenario."""
|
|
self._finalize_previous_scenario()
|
|
self._current_scenario = None
|