snappea

Check P specs against events emitted by your code during its tests.

Install the pysnappea distribution and import its API from snappea.

Application code calls emit(). Tests are checked either by the pytest plugin (every test, configured under [tool.snappea] in pyproject.toml) or explicitly with check_spec(), expect_violation() and check_events(). In production, set_sink() sends the same events to a file, stdout or a callback for snappea observe.

  1"""Check P specs against events emitted by your code during its tests.
  2
  3Install the ``pysnappea`` distribution and import its API from ``snappea``.
  4
  5Application code calls :func:`emit`. Tests are checked either by the pytest plugin
  6(every test, configured under ``[tool.snappea]`` in pyproject.toml) or explicitly with
  7:func:`check_spec`, :func:`expect_violation` and :func:`check_events`. In production,
  8:func:`set_sink` sends the same events to a file, stdout or a callback for
  9``snappea observe``.
 10"""
 11
 12from __future__ import annotations
 13
 14import re
 15from typing import Any, Optional, Sequence
 16
 17from ._binary import find_snappea
 18from ._emit import Event, Sink, emit, set_sink
 19from ._errors import SetupError, StrictDefaultWarning
 20from ._recorder import Recorder, SpecViolation, _deprecated, check_hot_arg, events_from_dicts
 21
 22# show `snappea.SpecViolation` in tracebacks, not the private module
 23for _cls in (Recorder, Event, SpecViolation, SetupError, StrictDefaultWarning, Sink):
 24    _cls.__module__ = "snappea"
 25
 26__all__ = [
 27    "emit",
 28    "set_sink",
 29    "Sink",
 30    "check_spec",
 31    "expect_violation",
 32    "check_events",
 33    "Recorder",
 34    "Event",
 35    "SpecViolation",
 36    "SetupError",
 37    "StrictDefaultWarning",
 38    "find_snappea",
 39]
 40
 41
 42def __getattr__(name: str) -> Any:
 43    if name == "SnappeaError":
 44        _deprecated("snappea.SnappeaError", "snappea.SetupError")
 45        return SetupError
 46    raise AttributeError(f"module 'snappea' has no attribute {name!r}")
 47
 48
 49class check_spec:
 50    """Checks the events emitted inside the block against P specs::
 51
 52        with check_spec("MutualExclusion") as rec:
 53            server.acquire("c1")
 54            server.acquire("c2")
 55
 56    The block observes events sent with :func:`emit`; it does not intercept ordinary
 57    method calls or run P drivers.
 58    No spec names means every spec in the spec files. Raises :class:`SpecViolation`
 59    (an ``AssertionError``) at the end of the block, so the test fails on this line,
 60    or :class:`SetupError` if the check cannot run. Options are those of
 61    :class:`Recorder`: ``check_hot=True`` also fails if a spec is left in a hot state;
 62    ``strict`` (default: true when specs are named) requires relevant events for every
 63    selected spec. When this named-spec default applies, missing relevant events currently
 64    warn; set ``strict=True`` explicitly to fail on them. ``unknown_events`` ("error" by
 65    default) handles undeclared names.
 66    """
 67
 68    def __init__(
 69        self,
 70        *specs: str,
 71        spec_files: Optional[Sequence[str]] = None,
 72        check_hot: Optional[bool] = None,
 73        strict: Optional[bool] = None,
 74        unknown_events: Optional[str] = None,
 75        timeout: Optional[float] = None,
 76        artifacts: Optional[str] = None,
 77        liveness: Optional[bool] = None,
 78    ):
 79        self.recorder = Recorder(
 80            specs,
 81            spec_files=spec_files,
 82            check_hot=check_hot_arg(check_hot, liveness),
 83            strict=strict,
 84            unknown_events=unknown_events,
 85            timeout=timeout,
 86            artifacts=artifacts,
 87        )
 88
 89    def __enter__(self) -> Recorder:
 90        return self.recorder.start()
 91
 92    def __exit__(self, exc_type: Any, exc: Any, tb: Any) -> None:
 93        __tracebackhide__ = True
 94        self.recorder.stop()
 95        if exc_type is None:
 96            self.recorder.check()
 97        else:
 98            self.recorder._cleanup_owned()
 99
100
101class expect_violation:
102    """The block must break `spec`, like ``pytest.raises``::
103
104        with expect_violation("MutualExclusion", match="c2 entered"):
105            server.acquire("c1")
106            server.acquire("c2")
107
108    The block passes if a :class:`SpecViolation` of `spec` is raised inside it (by
109    :func:`check_events` or a nested :func:`check_spec`) or if the events emitted inside
110    it break the spec. ``match`` is a regular expression searched for in the spec's
111    message. Enclosing checks, such as the pytest plugin's, ignore `spec` for the rest of
112    the test. The violation is available as ``.violation`` afterwards.
113    """
114
115    def __init__(
116        self,
117        spec: str,
118        match: Optional[str] = None,
119        *,
120        spec_files: Optional[Sequence[str]] = None,
121        check_hot: Optional[bool] = None,
122        unknown_events: Optional[str] = None,
123        timeout: Optional[float] = None,
124        artifacts: Optional[str] = None,
125        liveness: Optional[bool] = None,
126    ):
127        self.spec = spec
128        self.match = match
129        self.recorder = Recorder(
130            [spec],
131            spec_files=spec_files,
132            check_hot=check_hot_arg(check_hot, liveness),
133            strict=False,  # the spec must break; whether it saw events is beside the point
134            unknown_events=unknown_events,
135            timeout=timeout,
136            artifacts=artifacts,
137        )
138        self.violation: Optional[SpecViolation] = None
139
140    def __enter__(self) -> "expect_violation":
141        from . import _emit
142
143        for outer in _emit._recorders:
144            outer.ignored.add(self.spec)
145        self.recorder.start()
146        return self
147
148    def __exit__(self, exc_type: Any, exc: Any, tb: Any) -> bool:
149        __tracebackhide__ = True
150        self.recorder.stop()
151        found: Optional[SpecViolation] = None
152        if exc_type is not None:
153            if isinstance(exc, SpecViolation):
154                found = next((v for v in [exc, *exc.others] if v.spec == self.spec), None)
155            if found is None:
156                self.recorder._cleanup_owned()
157                return False  # some other failure: let it through
158        else:
159            found = next((v for v in self.recorder.violations() if v.spec == self.spec), None)
160        if found is None:
161            n = len(self.recorder.events)
162            raise AssertionError(f"expected P spec {self.spec} to be broken, but it held over {n} event(s)")
163        if self.match is not None and not re.search(self.match, found.message):
164            raise AssertionError(
165                f"P spec {self.spec} was broken, but its message {found.message!r} "
166                f"does not match {self.match!r}"
167            )
168        self.violation = found
169        return True
170
171
172def check_events(
173    events: Sequence[Any],
174    *specs: str,
175    spec_files: Optional[Sequence[str]] = None,
176    check_hot: Optional[bool] = None,
177    strict: Optional[bool] = None,
178    unknown_events: Optional[str] = None,
179    timeout: Optional[float] = None,
180    artifacts: Optional[str] = None,
181    liveness: Optional[bool] = None,
182) -> None:
183    """Check captured implementation events against P specs::
184
185        check_events(captured_events, "MutualExclusion")
186
187    Events should normally come from observed implementation behavior, with payloads
188    describing actual results and state. In Python tests, prefer application calls
189    to :func:`emit`, captured automatically by the pytest plugin or inside
190    :func:`check_spec`::
191
192        with check_spec("MutualExclusion"):
193            server.acquire("c1")
194            server.acquire("c2")
195
196    Use this function when you already have a captured event list. Hand-written
197    traces are useful for testing the spec itself; they do not validate the
198    implementation. For event logs, use ``snappea observe``.
199
200    Events take ``event`` and ``payload`` (or payload fields directly, as with
201    :func:`emit`) and optionally ``key`` and ``at``; without ``at`` they are checked in
202    list order. This checks the supplied trace against P monitors; it does not run P test
203    cases or explore schedules. No P entry machine or test declaration is needed for
204    observation. Keep observation-only monitors outside a model test project, or use a
205    reasoned ``unselected-spec`` lint suppression for intentional observation-only use.
206    When adapting captured data, preserve the identities and values the property needs;
207    collapsing distinct results can hide violations. These events do not reach any
208    enclosing check. Options are those of :func:`check_spec`.
209    """
210    __tracebackhide__ = True
211    rec = Recorder(
212        specs,
213        spec_files=spec_files,
214        check_hot=check_hot_arg(check_hot, liveness),
215        strict=strict,
216        unknown_events=unknown_events,
217        timeout=timeout,
218        artifacts=artifacts,
219    )
220    rec._events.extend(events_from_dicts(events))
221    rec.check()
def emit( event: str, payload: Any = <object object>, /, *, key: Any = None, at: Union[datetime.datetime, int, NoneType] = None, **fields: Any) -> None:
113def emit(event: str, payload: Any = _NO_PAYLOAD, /, *, key: Any = None, at: Union[datetime, int, None] = None, **fields: Any) -> None:
114    """Record that `event` happened, for P specs to check.
115
116    The payload is either the keyword arguments (``emit("eEnter", client="c1")``) or one
117    positional value, for events whose payload is not a named tuple or whose field names
118    clash with ``key``/``at`` (``emit("eClose", 7)``, ``emit("ePut", {"key": "k"})``).
119
120    ``key`` splits events into independent streams, each checked by its own spec
121    instance. ``at`` overrides the event time: a ``datetime``, or an integer of epoch
122    nanoseconds (any increasing counter works). Without a recording test or a sink set
123    with :func:`set_sink` this does nothing.
124    """
125    if not _recorders:
126        events_dir = os.environ.get(EVENTS_DIR_ENV)
127        if not events_dir:
128            if _sink is not None:
129                _sink._emit(event, payload, key, at, fields)
130            return
131    else:
132        events_dir = None
133    if payload is _NO_PAYLOAD:
134        payload = fields if fields else None
135    elif fields:
136        raise TypeError("emit() takes either a positional payload or keyword fields, not both")
137    ev = _make_event(event, payload, key, at, sys._getframe(1))
138    if events_dir is None:
139        for r in tuple(_recorders):
140            r._add(ev)
141    else:
142        _write_to_dir(events_dir, ev)

Record that event happened, for P specs to check.

The payload is either the keyword arguments (emit("eEnter", client="c1")) or one positional value, for events whose payload is not a named tuple or whose field names clash with key/at (emit("eClose", 7), emit("ePut", {"key": "k"})).

key splits events into independent streams, each checked by its own spec instance. at overrides the event time: a datetime, or an integer of epoch nanoseconds (any increasing counter works). Without a recording test or a sink set with set_sink() this does nothing.

def set_sink( target: Union[str, os.PathLike[str], Callable[[str], Any], Any, NoneType]) -> Optional[Sink]:
314def set_sink(target: SinkTarget) -> Optional[Sink]:
315    """Sends events to `target` when no test is recording, as the JSON lines
316    ``snappea observe`` reads. `target` is a file path (appended to), ``"-"`` or a text
317    stream such as ``sys.stdout``, a callable taking one line (no newline), or ``None``
318    to turn the sink off. Returns the new :class:`Sink` (``None`` for off).
319
320    Inside a recording test, or in a process a test started, events go to the test
321    instead. ``emit`` never raises or blocks because of the sink.
322    """
323    global _sink
324    old, _sink = _sink, None
325    if old is not None:
326        old.close()
327    if target is not None:
328        _sink = Sink(target)
329    return _sink

Sends events to target when no test is recording, as the JSON lines snappea observe reads. target is a file path (appended to), "-" or a text stream such as sys.stdout, a callable taking one line (no newline), or None to turn the sink off. Returns the new Sink (None for off).

Inside a recording test, or in a process a test started, events go to the test instead. emit never raises or blocks because of the sink.

class Sink:

Where emit() writes events outside tests. Made by set_sink().

Lines are queued and written by a background thread, so emit never blocks or raises. When the queue is full or a write fails, the event is dropped and counted in dropped.

Sink( target: Union[str, os.PathLike[str], Callable[[str], Any], Any, NoneType])
217    def __init__(self, target: SinkTarget):
218        self.target = target
219        #: Events dropped: queue full, a write failed, or a payload could not be encoded.
220        self.dropped = 0
221        self._write = self._writer(target)
222        self._lock = threading.Lock()
223        self._pid = -1
224        self._closed = False
225        self._queue: "queue.Queue[Optional[str]]" = queue.Queue(self.QUEUE_SIZE)
QUEUE_SIZE = 10000
def flush(self, timeout: float = 5.0) -> bool:
290    def flush(self, timeout: float = 5.0) -> bool:
291        """Waits up to `timeout` seconds for queued events to be written. Returns
292        whether they were."""
293        deadline = time.monotonic() + timeout
294        while self._queue.unfinished_tasks and self._pid == os.getpid():
295            if time.monotonic() >= deadline:
296                return False
297            time.sleep(0.005)
298        return True

Waits up to timeout seconds for queued events to be written. Returns whether they were.

def close(self, timeout: float = 5.0) -> None:
300    def close(self, timeout: float = 5.0) -> None:
301        """Writes what is queued and stops the writer. Later events are ignored."""
302        with self._lock:
303            if self._closed:
304                return
305            self._closed = True
306            if self._pid == os.getpid():
307                try:
308                    self._queue.put_nowait(None)
309                except queue.Full:
310                    pass
311        self.flush(timeout)

Writes what is queued and stops the writer. Later events are ignored.

class check_spec:
50class check_spec:
51    """Checks the events emitted inside the block against P specs::
52
53        with check_spec("MutualExclusion") as rec:
54            server.acquire("c1")
55            server.acquire("c2")
56
57    The block observes events sent with :func:`emit`; it does not intercept ordinary
58    method calls or run P drivers.
59    No spec names means every spec in the spec files. Raises :class:`SpecViolation`
60    (an ``AssertionError``) at the end of the block, so the test fails on this line,
61    or :class:`SetupError` if the check cannot run. Options are those of
62    :class:`Recorder`: ``check_hot=True`` also fails if a spec is left in a hot state;
63    ``strict`` (default: true when specs are named) requires relevant events for every
64    selected spec. When this named-spec default applies, missing relevant events currently
65    warn; set ``strict=True`` explicitly to fail on them. ``unknown_events`` ("error" by
66    default) handles undeclared names.
67    """
68
69    def __init__(
70        self,
71        *specs: str,
72        spec_files: Optional[Sequence[str]] = None,
73        check_hot: Optional[bool] = None,
74        strict: Optional[bool] = None,
75        unknown_events: Optional[str] = None,
76        timeout: Optional[float] = None,
77        artifacts: Optional[str] = None,
78        liveness: Optional[bool] = None,
79    ):
80        self.recorder = Recorder(
81            specs,
82            spec_files=spec_files,
83            check_hot=check_hot_arg(check_hot, liveness),
84            strict=strict,
85            unknown_events=unknown_events,
86            timeout=timeout,
87            artifacts=artifacts,
88        )
89
90    def __enter__(self) -> Recorder:
91        return self.recorder.start()
92
93    def __exit__(self, exc_type: Any, exc: Any, tb: Any) -> None:
94        __tracebackhide__ = True
95        self.recorder.stop()
96        if exc_type is None:
97            self.recorder.check()
98        else:
99            self.recorder._cleanup_owned()

Checks the events emitted inside the block against P specs::

with check_spec("MutualExclusion") as rec:
    server.acquire("c1")
    server.acquire("c2")

The block observes events sent with emit(); it does not intercept ordinary method calls or run P drivers. No spec names means every spec in the spec files. Raises SpecViolation (an AssertionError) at the end of the block, so the test fails on this line, or SetupError if the check cannot run. Options are those of Recorder: check_hot=True also fails if a spec is left in a hot state; strict (default: true when specs are named) requires relevant events for every selected spec. When this named-spec default applies, missing relevant events currently warn; set strict=True explicitly to fail on them. unknown_events ("error" by default) handles undeclared names.

check_spec( *specs: str, spec_files: Optional[Sequence[str]] = None, check_hot: Optional[bool] = None, strict: Optional[bool] = None, unknown_events: Optional[str] = None, timeout: Optional[float] = None, artifacts: Optional[str] = None, liveness: Optional[bool] = None)
69    def __init__(
70        self,
71        *specs: str,
72        spec_files: Optional[Sequence[str]] = None,
73        check_hot: Optional[bool] = None,
74        strict: Optional[bool] = None,
75        unknown_events: Optional[str] = None,
76        timeout: Optional[float] = None,
77        artifacts: Optional[str] = None,
78        liveness: Optional[bool] = None,
79    ):
80        self.recorder = Recorder(
81            specs,
82            spec_files=spec_files,
83            check_hot=check_hot_arg(check_hot, liveness),
84            strict=strict,
85            unknown_events=unknown_events,
86            timeout=timeout,
87            artifacts=artifacts,
88        )
recorder
class expect_violation:
102class expect_violation:
103    """The block must break `spec`, like ``pytest.raises``::
104
105        with expect_violation("MutualExclusion", match="c2 entered"):
106            server.acquire("c1")
107            server.acquire("c2")
108
109    The block passes if a :class:`SpecViolation` of `spec` is raised inside it (by
110    :func:`check_events` or a nested :func:`check_spec`) or if the events emitted inside
111    it break the spec. ``match`` is a regular expression searched for in the spec's
112    message. Enclosing checks, such as the pytest plugin's, ignore `spec` for the rest of
113    the test. The violation is available as ``.violation`` afterwards.
114    """
115
116    def __init__(
117        self,
118        spec: str,
119        match: Optional[str] = None,
120        *,
121        spec_files: Optional[Sequence[str]] = None,
122        check_hot: Optional[bool] = None,
123        unknown_events: Optional[str] = None,
124        timeout: Optional[float] = None,
125        artifacts: Optional[str] = None,
126        liveness: Optional[bool] = None,
127    ):
128        self.spec = spec
129        self.match = match
130        self.recorder = Recorder(
131            [spec],
132            spec_files=spec_files,
133            check_hot=check_hot_arg(check_hot, liveness),
134            strict=False,  # the spec must break; whether it saw events is beside the point
135            unknown_events=unknown_events,
136            timeout=timeout,
137            artifacts=artifacts,
138        )
139        self.violation: Optional[SpecViolation] = None
140
141    def __enter__(self) -> "expect_violation":
142        from . import _emit
143
144        for outer in _emit._recorders:
145            outer.ignored.add(self.spec)
146        self.recorder.start()
147        return self
148
149    def __exit__(self, exc_type: Any, exc: Any, tb: Any) -> bool:
150        __tracebackhide__ = True
151        self.recorder.stop()
152        found: Optional[SpecViolation] = None
153        if exc_type is not None:
154            if isinstance(exc, SpecViolation):
155                found = next((v for v in [exc, *exc.others] if v.spec == self.spec), None)
156            if found is None:
157                self.recorder._cleanup_owned()
158                return False  # some other failure: let it through
159        else:
160            found = next((v for v in self.recorder.violations() if v.spec == self.spec), None)
161        if found is None:
162            n = len(self.recorder.events)
163            raise AssertionError(f"expected P spec {self.spec} to be broken, but it held over {n} event(s)")
164        if self.match is not None and not re.search(self.match, found.message):
165            raise AssertionError(
166                f"P spec {self.spec} was broken, but its message {found.message!r} "
167                f"does not match {self.match!r}"
168            )
169        self.violation = found
170        return True

The block must break spec, like pytest.raises::

with expect_violation("MutualExclusion", match="c2 entered"):
    server.acquire("c1")
    server.acquire("c2")

The block passes if a SpecViolation of spec is raised inside it (by check_events() or a nested check_spec()) or if the events emitted inside it break the spec. match is a regular expression searched for in the spec's message. Enclosing checks, such as the pytest plugin's, ignore spec for the rest of the test. The violation is available as .violation afterwards.

expect_violation( spec: str, match: Optional[str] = None, *, spec_files: Optional[Sequence[str]] = None, check_hot: Optional[bool] = None, unknown_events: Optional[str] = None, timeout: Optional[float] = None, artifacts: Optional[str] = None, liveness: Optional[bool] = None)
116    def __init__(
117        self,
118        spec: str,
119        match: Optional[str] = None,
120        *,
121        spec_files: Optional[Sequence[str]] = None,
122        check_hot: Optional[bool] = None,
123        unknown_events: Optional[str] = None,
124        timeout: Optional[float] = None,
125        artifacts: Optional[str] = None,
126        liveness: Optional[bool] = None,
127    ):
128        self.spec = spec
129        self.match = match
130        self.recorder = Recorder(
131            [spec],
132            spec_files=spec_files,
133            check_hot=check_hot_arg(check_hot, liveness),
134            strict=False,  # the spec must break; whether it saw events is beside the point
135            unknown_events=unknown_events,
136            timeout=timeout,
137            artifacts=artifacts,
138        )
139        self.violation: Optional[SpecViolation] = None
spec
match
recorder
violation: Optional[SpecViolation]
def check_events( events: Sequence[Any], *specs: str, spec_files: Optional[Sequence[str]] = None, check_hot: Optional[bool] = None, strict: Optional[bool] = None, unknown_events: Optional[str] = None, timeout: Optional[float] = None, artifacts: Optional[str] = None, liveness: Optional[bool] = None) -> None:
173def check_events(
174    events: Sequence[Any],
175    *specs: str,
176    spec_files: Optional[Sequence[str]] = None,
177    check_hot: Optional[bool] = None,
178    strict: Optional[bool] = None,
179    unknown_events: Optional[str] = None,
180    timeout: Optional[float] = None,
181    artifacts: Optional[str] = None,
182    liveness: Optional[bool] = None,
183) -> None:
184    """Check captured implementation events against P specs::
185
186        check_events(captured_events, "MutualExclusion")
187
188    Events should normally come from observed implementation behavior, with payloads
189    describing actual results and state. In Python tests, prefer application calls
190    to :func:`emit`, captured automatically by the pytest plugin or inside
191    :func:`check_spec`::
192
193        with check_spec("MutualExclusion"):
194            server.acquire("c1")
195            server.acquire("c2")
196
197    Use this function when you already have a captured event list. Hand-written
198    traces are useful for testing the spec itself; they do not validate the
199    implementation. For event logs, use ``snappea observe``.
200
201    Events take ``event`` and ``payload`` (or payload fields directly, as with
202    :func:`emit`) and optionally ``key`` and ``at``; without ``at`` they are checked in
203    list order. This checks the supplied trace against P monitors; it does not run P test
204    cases or explore schedules. No P entry machine or test declaration is needed for
205    observation. Keep observation-only monitors outside a model test project, or use a
206    reasoned ``unselected-spec`` lint suppression for intentional observation-only use.
207    When adapting captured data, preserve the identities and values the property needs;
208    collapsing distinct results can hide violations. These events do not reach any
209    enclosing check. Options are those of :func:`check_spec`.
210    """
211    __tracebackhide__ = True
212    rec = Recorder(
213        specs,
214        spec_files=spec_files,
215        check_hot=check_hot_arg(check_hot, liveness),
216        strict=strict,
217        unknown_events=unknown_events,
218        timeout=timeout,
219        artifacts=artifacts,
220    )
221    rec._events.extend(events_from_dicts(events))
222    rec.check()

Check captured implementation events against P specs::

check_events(captured_events, "MutualExclusion")

Events should normally come from observed implementation behavior, with payloads describing actual results and state. In Python tests, prefer application calls to emit(), captured automatically by the pytest plugin or inside check_spec()::

with check_spec("MutualExclusion"):
    server.acquire("c1")
    server.acquire("c2")

Use this function when you already have a captured event list. Hand-written traces are useful for testing the spec itself; they do not validate the implementation. For event logs, use snappea observe.

Events take event and payload (or payload fields directly, as with emit()) and optionally key and at; without at they are checked in list order. This checks the supplied trace against P monitors; it does not run P test cases or explore schedules. No P entry machine or test declaration is needed for observation. Keep observation-only monitors outside a model test project, or use a reasoned unselected-spec lint suppression for intentional observation-only use. When adapting captured data, preserve the identities and values the property needs; collapsing distinct results can hide violations. These events do not reach any enclosing check. Options are those of check_spec().

class Recorder:

Collects the events emitted while it is active and checks them against P specs.

Every option left as None comes from [tool.snappea] in the nearest pyproject.toml (and the environment variables that override it), else its default:

  • specs: the specs to check; none means every spec in the spec files (except exclude).
  • spec_files: the P files: paths, globs, or folders meaning every .p in them. Relative to the working directory here; to the pyproject.toml in config.
  • strict: require a relevant event for every selected spec. Default: true when specs are named, false for every spec. When this named-spec default applies, missing relevant events currently warn; set strict=True explicitly to fail.
  • check_hot: also fail on a spec left in a hot state. Default: false.
  • unknown_events: an event name no P file declares is an "error" (default), a "warn"\ ing, or "ignore"\ d.
  • timeout: seconds the checker may run. Default: 30.
  • artifacts: folder to keep a failed check's events and output in. Default: a temp folder. SNAPPEA_ARTIFACTS overrides [tool.snappea].

After a check, report holds snappea's JSON result, with each spec's monitor coverage.

Recorder( specs: Iterable[str] = (), *, spec_files: Optional[Sequence[str]] = None, exclude: Optional[Iterable[str]] = None, strict: Optional[bool] = None, check_hot: Optional[bool] = None, unknown_events: Optional[str] = None, timeout: Optional[float] = None, artifacts: Optional[str] = None, liveness: Optional[bool] = None, output_root: Optional[str] = None)
154    def __init__(
155        self,
156        specs: Iterable[str] = (),
157        *,
158        spec_files: Optional[Sequence[str]] = None,
159        exclude: Optional[Iterable[str]] = None,
160        strict: Optional[bool] = None,
161        check_hot: Optional[bool] = None,
162        unknown_events: Optional[str] = None,
163        timeout: Optional[float] = None,
164        artifacts: Optional[str] = None,
165        liveness: Optional[bool] = None,
166        output_root: Optional[str] = None,
167    ):
168        if isinstance(specs, str):
169            specs = [specs]
170        self.specs: List[str] = list(specs)
171        self.spec_files = [os.fspath(f) for f in spec_files] if spec_files is not None else None
172        self.exclude: Optional[List[str]] = list(exclude) if exclude is not None else None
173        self.check_hot = check_hot_arg(check_hot, liveness)
174        self.strict = strict
175        if unknown_events is not None:
176            _config.check_unknown_events(unknown_events, "unknown_events")
177        self.unknown_events = unknown_events
178        self.timeout = timeout
179        if output_root is not None:
180            _deprecated("output_root=", "artifacts=")
181            artifacts = artifacts or output_root
182        self.artifacts = os.fspath(artifacts) if artifacts is not None else None
183        #: snappea's JSON result of the last check that ran (``specs[].coverage`` holds
184        #: each spec's monitor coverage); None before, or if snappea wrote none.
185        self.report: Optional[dict] = None
186        #: Specs whose violations this recorder ignores (broken on purpose inside
187        #: `expect_violation`).
188        self.ignored: set = set()
189        self._events: List[Event] = []
190        self._logs: List[Tuple[str, Optional[LogParser]]] = []
191        self._dirs: List[str] = []
192        self._owned: List[str] = []
193        self._active = False
194        self._prev_env: Optional[str] = None
195        self._child_dir: Optional[str] = None
196        # set by the pytest plugin: its configuration, and pytest's temp folder
197        self._cfg: Optional[_config.Config] = None
198        self._default_artifacts: Optional[str] = None
liveness: Optional[bool]
200    @property
201    def liveness(self) -> Optional[bool]:
202        """Deprecated: use :attr:`check_hot`."""
203        _deprecated("Recorder.liveness", "Recorder.check_hot")
204        return self.check_hot

Deprecated: use check_hot.

def start(self) -> Recorder:
208    def start(self) -> "Recorder":
209        """Starts collecting: events emitted in this process, and in processes started
210        from now on (through the `SNAPPEA_EVENTS_DIR` environment variable)."""
211        if self._active:
212            return self
213        events_dir = self.events_dir()
214        self._prev_env = os.environ.get(_emit.EVENTS_DIR_ENV)
215        os.environ[_emit.EVENTS_DIR_ENV] = events_dir
216        _emit._recorders.append(self)
217        self._active = True
218        return self

Starts collecting: events emitted in this process, and in processes started from now on (through the SNAPPEA_EVENTS_DIR environment variable).

def stop(self) -> None:
220    def stop(self) -> None:
221        """Stops collecting. Events child processes wrote are passed on to the recorders
222        still active, so an enclosing test-wide check sees them too."""
223        if not self._active:
224            return
225        self._active = False
226        if self in _emit._recorders:
227            _emit._recorders.remove(self)
228        if self._prev_env is None:
229            os.environ.pop(_emit.EVENTS_DIR_ENV, None)
230        else:
231            os.environ[_emit.EVENTS_DIR_ENV] = self._prev_env
232        if _emit._recorders and self._child_dir and self._child_dir in self._owned:
233            for outer in _emit._recorders:
234                outer._dirs.append(self._child_dir)
235            # the outermost recorder deletes the folder when it is done
236            _emit._recorders[0]._owned.append(self._child_dir)
237            self._owned.remove(self._child_dir)

Stops collecting. Events child processes wrote are passed on to the recorders still active, so an enclosing test-wide check sees them too.

def events_dir(self) -> str:
248    def events_dir(self) -> str:
249        """This recording's events folder: the `SNAPPEA_EVENTS_DIR` of processes it starts.
250        Created on first use; deleted after the check."""
251        if self._child_dir is None:
252            self._child_dir = tempfile.mkdtemp(prefix="snappea-events-")
253            self._owned.append(self._child_dir)
254            self._dirs.append(self._child_dir)
255        return self._child_dir

This recording's events folder: the SNAPPEA_EVENTS_DIR of processes it starts. Created on first use; deleted after the check.

def env(self) -> Dict[str, str]:
257    def env(self) -> Dict[str, str]:
258        """The current environment plus `SNAPPEA_EVENTS_DIR`, for starting a child process:
259        ``subprocess.run(cmd, env=rec.env())``."""
260        return {**os.environ, _emit.EVENTS_DIR_ENV: self.events_dir()}

The current environment plus SNAPPEA_EVENTS_DIR, for starting a child process: subprocess.run(cmd, env=rec.env()).

def add_log( self, path: str, parse: Optional[Callable[[str], Optional[dict]]] = None) -> None:
262    def add_log(self, path: str, parse: Optional[LogParser] = None) -> None:
263        """Also check the events in the log file at `path`, read when the check runs.
264
265        ``parse`` turns one line into an event or ``None``: a dict like ``emit``'s
266        arguments, ``{"event": "eEnter", "key": "L1", "at": when, "client": "c1"}``
267        (``at`` is required so events can be merged by time). Without ``parse`` the file
268        must hold snappea's JSON lines."""
269        self._logs.append((os.path.abspath(os.fspath(path)), parse))

Also check the events in the log file at path, read when the check runs.

parse turns one line into an event or None: a dict like emit's arguments, {"event": "eEnter", "key": "L1", "at": when, "client": "c1"} (at is required so events can be merged by time). Without parse the file must hold snappea's JSON lines.

def add_events_dir(self, path: str) -> None:
271    def add_events_dir(self, path: str) -> None:
272        """Also check the JSON-lines files in folder `path`, e.g. one mounted into a
273        container whose service writes events there. The folder must exist at check time."""
274        self._dirs.append(os.path.abspath(os.fspath(path)))

Also check the JSON-lines files in folder path, e.g. one mounted into a container whose service writes events there. The folder must exist at check time.

events: List[Event]
278    @property
279    def events(self) -> List[Event]:
280        """Every event collected so far: emitted here, by child processes and in logs,
281        in time order."""
282        evs = list(self._events) + [e for _, e in self._external_lines()]
283        evs.sort(key=lambda e: e.time_ns)
284        return evs

Every event collected so far: emitted here, by child processes and in logs, in time order.

def events_named(self, name: str) -> List[Any]:
286    def events_named(self, name: str) -> List[Any]:
287        """The payloads of the events called `name`, in order."""
288        return [e.payload for e in self.events if e.event == name]

The payloads of the events called name, in order.

def count(self, name: str) -> int:
290    def count(self, name: str) -> int:
291        return sum(1 for e in self.events if e.event == name)
def check(self) -> None:
319    def check(self) -> None:
320        """Runs snappea over the events; raises SpecViolation if a spec was broken, and
321        SetupError if the check could not run."""
322        __tracebackhide__ = True
323        violations = self.violations()
324        if violations:
325            first = violations[0]
326            first.others = violations[1:]
327            first.args = (first.report(),)
328            raise first

Runs snappea over the events; raises SpecViolation if a spec was broken, and SetupError if the check could not run.

def violations(self) -> List[SpecViolation]:
330    def violations(self) -> List[SpecViolation]:
331        """Runs snappea over the events and returns the violations found (empty if none)."""
332        __tracebackhide__ = True
333        try:
334            settings = self._settings()
335            if not self._events and not self._logs and not any(os.path.getsize(p) for p in self._dir_files()):
336                # nothing happened: every spec still holds, unless strict wants events
337                if not settings.strict:
338                    return []
339            return self._run(settings)
340        finally:
341            self._cleanup_owned()

Runs snappea over the events and returns the violations found (empty if none).

class Event:

One emitted event.

Event( event: str, payload: Any, key: str, time_ns: int, at: bool = False, src: Optional[str] = None, seq: int = 0, pid: int = <factory>)
at: bool = False
src: Optional[str] = None
seq: int = 0
def to_json(self) -> str:
53    def to_json(self) -> str:
54        """The JSON line `snappea observe` reads, plus fields used for reports."""
55        d: dict = {
56            "key": self.key,
57            "timestamp": iso_timestamp(self.time_ns),
58            "event": self.event,
59            "payload": _plain(self.payload),
60            "src": self.src,
61            "seq": self.seq,
62            "pid": self.pid,
63        }
64        if self.at:
65            d["at"] = True
66        return json.dumps(d, default=_json_default, separators=(",", ":"))

The JSON line snappea observe reads, plus fields used for reports.

@staticmethod
def from_json(d: dict) -> Event:
68    @staticmethod
69    def from_json(d: dict) -> "Event":
70        return Event(
71            event=d.get("event", ""),
72            payload=d.get("payload"),
73            key=_key_str(d.get("key")),
74            time_ns=0,
75            at=bool(d.get("at")),
76            src=d.get("src"),
77            seq=d.get("seq", 0) or 0,
78            pid=d.get("pid", 0) or 0,
79        )
event: str
payload: Any
key: str
time_ns: int
pid: int
class SpecViolation(builtins.AssertionError):

A P spec was broken by the recorded events.

SpecViolation( spec: str, message: str, key: str = '', events: Sequence[Event] = (), *, location: Optional[str] = None, liveness: bool = False, earlier: int = 0, output_dir: Optional[str] = None, others: Sequence[SpecViolation] = ())
43    def __init__(
44        self,
45        spec: str,
46        message: str,
47        key: str = "",
48        events: Sequence[Event] = (),
49        *,
50        location: Optional[str] = None,
51        liveness: bool = False,
52        earlier: int = 0,
53        output_dir: Optional[str] = None,
54        others: Sequence["SpecViolation"] = (),
55    ):
56        #: Name of the broken spec.
57        self.spec = spec
58        #: The spec's assert message (or, for liveness, which hot state it ended in).
59        self.message = message
60        #: Event key (stream) the violation happened on.
61        self.key = key
62        #: Events of that key up to and including the violating one (the last ones only).
63        self.events = list(events)
64        #: `file:line` of the failed assert in the P sources.
65        self.location = location
66        #: The spec ended in a hot state (found with `check_hot`).
67        self.liveness = liveness
68        #: Events of this key before `events` that are not shown.
69        self.earlier = earlier
70        #: Folder with the events and snappea's full output (the check's artifacts).
71        self.output_dir = output_dir
72        #: Further violations found by the same check.
73        self.others = list(others)
74        super().__init__(self.report())
def report(self) -> str:
 76    def report(self) -> str:
 77        lines = []
 78        if self.liveness:
 79            lines.append(f"P spec {self.spec} was still waiting when the events ended: {self.message}")
 80        else:
 81            lines.append(f"P spec {self.spec} was broken: {self.message}")
 82        lines.append("")
 83        lines.append(f'  Events (key "{self.key}"):' if self.key else "  Events:")
 84        if self.earlier:
 85            lines.append(f"     ... {self.earlier} earlier event(s)")
 86        rows = [(_payload_text(e.payload), e) for e in self.events]
 87        w_event = max([len(e.event) for _, e in rows] + [0])
 88        w_payload = min(max([len(p) for p, _ in rows] + [0]), 48)
 89        for i, (p, e) in enumerate(rows):
 90            n = self.earlier + i + 1
 91            row = f"    {n:>3}  {e.event:<{w_event}}  {p:<{w_payload}}  {e.src or ''}"
 92            if e.at:
 93                row += "  (time set with at=)"
 94            if i == len(rows) - 1:
 95                row += "  <- events ended here" if self.liveness else "  <- broke the spec here"
 96            lines.append(row.rstrip() if i < len(rows) - 1 else row)
 97        if not rows:
 98            lines.append("    (none)")
 99        lines.append("")
100        if self.location:
101            lines.append(f"  Spec: {self.location}")
102        if self.output_dir:
103            lines.append(f"  snappea output: {self.output_dir}")
104        if self.others:
105            lines.append("")
106            lines.append(f"  {len(self.others)} more violation(s):")
107            for o in self.others:
108                key = f' (key "{o.key}")' if o.key else ""
109                lines.append(f"    {o.spec}{key}: {o.message}")
110        return "\n".join(lines)
class SetupError(builtins.Exception):

The check could not run, or its input was bad: anything but a broken spec.

kind says why, for assertions without matching message text:

  • binary_missing: no snappea binary
  • config: bad options or [tool.snappea], missing spec files, P compile errors, an unknown spec name
  • parser: an event line or payload snappea cannot read (including truncated lines)
  • validation: strict found a selected spec with no relevant event
  • unknown_event: an event name no P file declares, under unknown_events="error"
  • out_of_order: live input out of order
  • timeout: the checker ran longer than timeout
SetupError( message: str, kind: str = 'config', *, output_dir: Optional[str] = None)
30    def __init__(self, message: str, kind: str = "config", *, output_dir: Optional[str] = None):
31        super().__init__(message)
32        #: One of :data:`KINDS` (or a newer kind the core reports).
33        self.kind = kind
34        #: The error text.
35        self.message = message
36        #: Folder with the events and snappea's output, if the check got that far.
37        self.output_dir = output_dir
class StrictDefaultWarning(builtins.DeprecationWarning):

Deprecated and no longer emitted; kept so existing references keep working.

It warned, for one release, that a check with named specs would fail under the new strict default. That default is now in force: such a check raises SetupError with kind == "validation". Pass strict=False (or set [tool.snappea] strict = false) when a spec may rightly see no events.

StrictDefaultWarning(error: SetupError)
49    def __init__(self, error: SetupError):
50        super().__init__(error.message)
51        self.error = error
@lru_cache(maxsize=None)
def find_snappea() -> str:
27@lru_cache(maxsize=None)
28def find_snappea() -> str:
29    """Path of the `snappea` binary: `SNAPPEA_BIN`, the copy installed with this package, or
30    `snappea` on PATH."""
31    name, override = BINARY_ENV, os.environ.get(BINARY_ENV)
32    if not override and os.environ.get(OLD_BINARY_ENV):
33        name, override = OLD_BINARY_ENV, os.environ[OLD_BINARY_ENV]
34        warn(f"{OLD_BINARY_ENV} is deprecated; set {BINARY_ENV} instead", DeprecationWarning)
35    if override:
36        if not os.path.isfile(override):
37            raise SnappeaNotFound(f"{name}={override} is not a file")
38        return os.path.abspath(override)
39    exe = "snappea" + (sysconfig.get_config_var("EXE") or "")
40    # wheels install the binary next to the interpreter's scripts
41    candidates = [sysconfig.get_path("scripts")]
42    user_scheme = {"nt": "nt_user", "posix": "posix_user"}.get(os.name)
43    if sys.platform == "darwin" and sys._framework:  # type: ignore[attr-defined]
44        user_scheme = "osx_framework_user"
45    if user_scheme:
46        try:
47            candidates.append(sysconfig.get_path("scripts", scheme=user_scheme))
48        except KeyError:
49            pass
50    candidates.append(os.path.dirname(sys.executable))
51    for d in candidates:
52        p = os.path.join(d, exe)
53        if os.path.isfile(p):
54            return p
55    found = shutil.which("snappea")
56    if found:
57        return found
58    raise SnappeaNotFound(
59        "Could not find the snappea binary. Install the snappea wheel for your platform "
60        f"(pip install pysnappea), put snappea on PATH (cargo install snappea), or set {BINARY_ENV}."
61    )

Path of the snappea binary: SNAPPEA_BIN, the copy installed with this package, or snappea on PATH.