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()
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.
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.
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.
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)
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.
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.
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.
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 )
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.
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
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().
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 (exceptexclude).spec_files: the P files: paths, globs, or folders meaning every.pin them. Relative to the working directory here; to the pyproject.toml in config.strict: require a relevant event for every selected spec. Default: true whenspecsare named, false for every spec. When this named-spec default applies, missing relevant events currently warn; setstrict=Trueexplicitly 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_ARTIFACTSoverrides[tool.snappea].
After a check, report holds snappea's JSON result, with each spec's
monitor coverage.
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
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.
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).
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.
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.
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()).
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.
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.
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.
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.
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.
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).
One emitted event.
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.
A P spec was broken by the recorded events.
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())
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)
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: nosnappeabinaryconfig: bad options or[tool.snappea], missing spec files, P compile errors, an unknown spec nameparser: an event line or payload snappea cannot read (including truncated lines)validation:strictfound a selected spec with no relevant eventunknown_event: an event name no P file declares, underunknown_events="error"out_of_order: live input out of ordertimeout: the checker ran longer thantimeout
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
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.
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 )