1# This file is part of Hypothesis, which may be found at
2# https://github.com/HypothesisWorks/hypothesis/
3#
4# Copyright the Hypothesis Authors.
5# Individual contributors are listed in AUTHORS.rst and the git log.
6#
7# This Source Code Form is subject to the terms of the Mozilla Public License,
8# v. 2.0. If a copy of the MPL was not distributed with this file, You can
9# obtain one at https://mozilla.org/MPL/2.0/.
10
11"""Observability tools to spit out analysis-ready tables, one row per test case."""
12
13import base64
14import dataclasses
15import json
16import math
17import os
18import sys
19import threading
20import time
21import warnings
22from collections.abc import Callable, Generator
23from contextlib import contextmanager
24from dataclasses import dataclass
25from datetime import date, timedelta
26from functools import lru_cache
27from pathlib import Path
28from threading import Lock
29from typing import (
30 TYPE_CHECKING,
31 Any,
32 Literal,
33 Optional,
34 TypeAlias,
35 Union,
36 cast,
37)
38
39from hypothesis.configuration import storage_directory
40from hypothesis.errors import HypothesisWarning
41from hypothesis.internal.conjecture.choice import (
42 BooleanConstraints,
43 BytesConstraints,
44 ChoiceConstraintsT,
45 ChoiceNode,
46 ChoiceT,
47 ChoiceTypeT,
48 FloatConstraints,
49 IntegerConstraints,
50 StringConstraints,
51)
52from hypothesis.internal.escalation import InterestingOrigin
53from hypothesis.internal.floats import float_to_int
54from hypothesis.internal.intervalsets import IntervalSet
55from hypothesis.utils.deprecation import note_deprecation
56
57if TYPE_CHECKING:
58 from hypothesis.internal.conjecture.data import ConjectureData, Spans, Status
59
60
61Observation: TypeAlias = Union["InfoObservation", "TestCaseObservation"]
62CallbackThreadT: TypeAlias = Callable[[Observation], None]
63# for all_threads=True, we pass the thread id as well.
64CallbackAllThreadsT: TypeAlias = Callable[[Observation, int], None]
65CallbackT: TypeAlias = CallbackThreadT | CallbackAllThreadsT
66
67# thread_id: list[callback]
68_callbacks: dict[int | None, list[CallbackThreadT]] = {}
69# callbacks where all_threads=True was set
70_callbacks_all_threads: list[CallbackAllThreadsT] = []
71
72
73@dataclass(slots=True, frozen=False)
74class PredicateCounts:
75 satisfied: int = 0
76 unsatisfied: int = 0
77
78 def update_count(self, *, condition: bool) -> None:
79 if condition:
80 self.satisfied += 1
81 else:
82 self.unsatisfied += 1
83
84
85def _choice_to_json(choice: ChoiceT | None) -> Any:
86 if choice is None:
87 return None
88 # see the note on the same check in to_jsonable for why we cast large
89 # integers to floats.
90 if (
91 isinstance(choice, int)
92 and not isinstance(choice, bool)
93 and abs(choice) >= 2**63
94 ):
95 return ["integer", str(choice)]
96 elif isinstance(choice, bytes):
97 return ["bytes", base64.b64encode(choice).decode()]
98 elif isinstance(choice, float) and math.isnan(choice):
99 # handle nonstandard nan bit patterns. We don't need to do this for -0.0
100 # vs 0.0 since json doesn't normalize -0.0 to 0.0.
101 return ["float", float_to_int(choice)]
102 return choice
103
104
105def choices_to_json(choices: tuple[ChoiceT, ...]) -> list[Any]:
106 return [_choice_to_json(choice) for choice in choices]
107
108
109def _constraints_to_json(
110 choice_type: ChoiceTypeT, constraints: ChoiceConstraintsT
111) -> dict[str, Any]:
112 constraints = constraints.copy()
113 if choice_type == "integer":
114 constraints = cast(IntegerConstraints, constraints)
115 return {
116 "min_value": _choice_to_json(constraints["min_value"]),
117 "max_value": _choice_to_json(constraints["max_value"]),
118 "weights": (
119 None
120 if constraints["weights"] is None
121 # wrap up in a list, instead of a dict, because json dicts
122 # require string keys
123 else [
124 (_choice_to_json(k), v) for k, v in constraints["weights"].items()
125 ]
126 ),
127 "shrink_towards": _choice_to_json(constraints["shrink_towards"]),
128 }
129 elif choice_type == "float":
130 constraints = cast(FloatConstraints, constraints)
131 return {
132 "min_value": _choice_to_json(constraints["min_value"]),
133 "max_value": _choice_to_json(constraints["max_value"]),
134 "allow_nan": constraints["allow_nan"],
135 "smallest_nonzero_magnitude": constraints["smallest_nonzero_magnitude"],
136 }
137 elif choice_type == "string":
138 constraints = cast(StringConstraints, constraints)
139 assert isinstance(constraints["intervals"], IntervalSet)
140 return {
141 "intervals": constraints["intervals"].intervals,
142 "min_size": _choice_to_json(constraints["min_size"]),
143 "max_size": _choice_to_json(constraints["max_size"]),
144 }
145 elif choice_type == "bytes":
146 constraints = cast(BytesConstraints, constraints)
147 return {
148 "min_size": _choice_to_json(constraints["min_size"]),
149 "max_size": _choice_to_json(constraints["max_size"]),
150 }
151 elif choice_type == "boolean":
152 constraints = cast(BooleanConstraints, constraints)
153 return {
154 "p": constraints["p"],
155 }
156 else:
157 raise NotImplementedError(f"unknown choice type {choice_type}")
158
159
160def nodes_to_json(nodes: tuple[ChoiceNode, ...]) -> list[dict[str, Any]]:
161 return [
162 {
163 "type": node.type,
164 "value": _choice_to_json(node.value),
165 "constraints": _constraints_to_json(node.type, node.constraints),
166 "was_forced": node.was_forced,
167 }
168 for node in nodes
169 ]
170
171
172@dataclass(slots=True, frozen=True)
173class ObservationMetadata:
174 traceback: str | None
175 reproduction_decorator: str | None
176 notes: list[str]
177 predicates: dict[str, PredicateCounts]
178 backend: dict[str, Any]
179 sys_argv: list[str]
180 os_getpid: int
181 imported_at: float
182 data_status: "Status"
183 phase: str
184 interesting_origin: InterestingOrigin | None
185 choice_nodes: tuple[ChoiceNode, ...] | None
186 choice_spans: Optional["Spans"]
187
188 def to_json(self) -> dict[str, Any]:
189 data = {
190 "traceback": self.traceback,
191 "reproduction_decorator": self.reproduction_decorator,
192 "notes": self.notes,
193 "predicates": self.predicates,
194 "backend": self.backend,
195 "sys.argv": self.sys_argv,
196 "os.getpid()": self.os_getpid,
197 "imported_at": self.imported_at,
198 "data_status": self.data_status,
199 "phase": self.phase,
200 "interesting_origin": self.interesting_origin,
201 "choice_nodes": (
202 None if self.choice_nodes is None else nodes_to_json(self.choice_nodes)
203 ),
204 "choice_spans": (
205 None
206 if self.choice_spans is None
207 else [
208 (
209 # span.label is an int, but cast to string to avoid conversion
210 # to float (and loss of precision) for large label values.
211 #
212 # The value of this label is opaque to consumers anyway, so its
213 # type shouldn't matter as long as it's consistent.
214 str(span.label),
215 span.start,
216 span.end,
217 span.discarded,
218 )
219 for span in self.choice_spans
220 ]
221 ),
222 }
223 # check that we didn't forget one
224 assert len(data) == len(dataclasses.fields(self))
225 return data
226
227
228@dataclass(slots=True, frozen=True)
229class BaseObservation:
230 type: Literal["test_case", "info", "alert", "error"]
231 property: str
232 run_start: float
233
234
235InfoObservationType = Literal["info", "alert", "error"]
236TestCaseStatus = Literal["gave_up", "passed", "failed"]
237
238
239@dataclass(slots=True, frozen=True)
240class InfoObservation(BaseObservation):
241 type: InfoObservationType
242 title: str
243 content: str | dict
244
245
246@dataclass(slots=True, frozen=True)
247class TestCaseObservation(BaseObservation):
248 __test__ = False # no! bad pytest!
249
250 type: Literal["test_case"]
251 status: TestCaseStatus
252 status_reason: str
253 representation: str
254 arguments: dict
255 how_generated: str
256 features: dict
257 coverage: dict[str, list[int]] | None
258 timing: dict[str, float]
259 metadata: ObservationMetadata
260
261
262def add_observability_callback(f: CallbackT, /, *, all_threads: bool = False) -> None:
263 """
264 Adds ``f`` as a callback for |observability|. ``f``
265 should accept one argument, which is an observation. Whenever Hypothesis
266 produces a new observation, it calls each callback with that observation.
267
268 If Hypothesis tests are being run from multiple threads, callbacks are tracked
269 per-thread. In other words, ``add_observability_callback(f)`` only adds ``f``
270 as an observability callback for observations produced on that thread.
271
272 If ``all_threads=True`` is passed, ``f`` will instead be registered as a
273 callback for all threads. This means it will be called for observations
274 generated by all threads, not just the thread which registered ``f`` as a
275 callback. In this case, ``f`` will be passed two arguments: the first is the
276 observation, and the second is the integer thread id from
277 :func:`python:threading.get_ident` where that observation was generated.
278
279 We recommend against registering ``f`` as a callback for both ``all_threads=True``
280 and the default ``all_threads=False``, due to unclear semantics with
281 |remove_observability_callback|.
282 """
283 if all_threads:
284 _callbacks_all_threads.append(cast(CallbackAllThreadsT, f))
285 return
286
287 thread_id = threading.get_ident()
288 if thread_id not in _callbacks:
289 _callbacks[thread_id] = []
290
291 _callbacks[thread_id].append(cast(CallbackThreadT, f))
292
293
294def remove_observability_callback(f: CallbackT, /) -> None:
295 """
296 Removes ``f`` from the |observability| callbacks.
297
298 If ``f`` is not in the list of observability callbacks, silently do nothing.
299
300 If running under multiple threads, ``f`` will only be removed from the
301 callbacks for this thread.
302 """
303 if f in _callbacks_all_threads:
304 _callbacks_all_threads.remove(f)
305
306 thread_id = threading.get_ident()
307 if thread_id not in _callbacks:
308 return
309
310 callbacks = _callbacks[thread_id]
311 if f in callbacks:
312 callbacks.remove(f)
313
314 if not callbacks:
315 del _callbacks[thread_id]
316
317
318def observability_enabled() -> bool:
319 """
320 Returns whether or not Hypothesis considers |observability|
321 to be enabled. Observability is enabled if there is at least one observability
322 callback present.
323
324 Callers might use this method to determine whether they should compute an
325 expensive representation that is only used under observability, for instance
326 by |alternative backends|.
327 """
328 return bool(_callbacks) or bool(_callbacks_all_threads)
329
330
331@contextmanager
332def with_observability_callback(
333 f: Callable[[Observation], None], /, *, all_threads: bool = False
334) -> Generator[None, None, None]:
335 """
336 A simple context manager which calls |add_observability_callback| on ``f``
337 when it enters and |remove_observability_callback| on ``f`` when it exits.
338 """
339 add_observability_callback(f, all_threads=all_threads)
340 try:
341 yield
342 finally:
343 remove_observability_callback(f)
344
345
346def deliver_observation(observation: Observation) -> None:
347 thread_id = threading.get_ident()
348
349 for callback in _callbacks.get(thread_id, []):
350 callback(observation)
351
352 for callback in _callbacks_all_threads:
353 callback(observation, thread_id)
354
355
356class _TestcaseCallbacks:
357 def __bool__(self):
358 self._note_deprecation()
359 return bool(_callbacks)
360
361 def _note_deprecation(self):
362 note_deprecation(
363 "hypothesis.internal.observability.TESTCASE_CALLBACKS is deprecated. "
364 "Replace TESTCASE_CALLBACKS.append with add_observability_callback, "
365 "TESTCASE_CALLBACKS.remove with remove_observability_callback, and "
366 "bool(TESTCASE_CALLBACKS) with observability_enabled().",
367 since="2025-08-01",
368 has_codemod=False,
369 )
370
371 def append(self, f):
372 self._note_deprecation()
373 add_observability_callback(f)
374
375 def remove(self, f):
376 self._note_deprecation()
377 remove_observability_callback(f)
378
379
380#: .. warning::
381#:
382#: Deprecated in favor of |add_observability_callback|,
383#: |remove_observability_callback|, and |observability_enabled|.
384#:
385#: |TESTCASE_CALLBACKS| remains a thin compatibility
386#: shim which forwards ``.append``, ``.remove``, and ``bool()`` to those
387#: three methods. It is not an attempt to be fully compatible with the previous
388#: ``TESTCASE_CALLBACKS = []``, so iteration or other usages will not work
389#: anymore. Please update to using the new methods instead.
390#:
391#: |TESTCASE_CALLBACKS| will eventually be removed.
392TESTCASE_CALLBACKS = _TestcaseCallbacks()
393
394
395def make_testcase(
396 *,
397 run_start: float,
398 property: str,
399 data: "ConjectureData",
400 how_generated: str,
401 representation: str = "<unknown>",
402 timing: dict[str, float],
403 arguments: dict | None = None,
404 coverage: dict[str, list[int]] | None = None,
405 phase: str | None = None,
406 backend_metadata: dict[str, Any] | None = None,
407 status: (
408 Union[TestCaseStatus, "Status"] | None
409 ) = None, # overrides automatic calculation
410 status_reason: str | None = None, # overrides automatic calculation
411 # added to calculated metadata. If keys overlap, the value from this `metadata`
412 # is used
413 metadata: dict[str, Any] | None = None,
414) -> TestCaseObservation:
415 from hypothesis.core import reproduction_decorator
416 from hypothesis.internal.conjecture.data import Status
417
418 # We should only be sending observability reports for datas that have finished
419 # being modified.
420 assert data.frozen
421
422 if status_reason is not None:
423 pass
424 elif data.interesting_origin:
425 status_reason = str(data.interesting_origin)
426 elif phase == "shrink" and data.status == Status.OVERRUN:
427 status_reason = "exceeded size of current best example"
428 else:
429 status_reason = str(data.events.pop("invalid because", ""))
430
431 status_map: dict[Status, TestCaseStatus] = {
432 Status.OVERRUN: "gave_up",
433 Status.INVALID: "gave_up",
434 Status.VALID: "passed",
435 Status.INTERESTING: "failed",
436 }
437
438 if status is not None and isinstance(status, Status):
439 status = status_map[status]
440 if status is None:
441 status = status_map[data.status]
442
443 return TestCaseObservation(
444 type="test_case",
445 status=status,
446 status_reason=status_reason,
447 representation=representation,
448 arguments={
449 k.removeprefix("generate:"): v for k, v in (arguments or {}).items()
450 },
451 how_generated=how_generated, # iid, mutation, etc.
452 features={
453 **{
454 f"target:{k}".strip(":"): v for k, v in data.target_observations.items()
455 },
456 **data.events,
457 },
458 coverage=coverage,
459 timing=timing,
460 metadata=ObservationMetadata(
461 **{
462 "traceback": data.expected_traceback,
463 "reproduction_decorator": (
464 reproduction_decorator(data.choices) if status == "failed" else None
465 ),
466 "notes": list(data.notes),
467 "predicates": dict(data._observability_predicates),
468 "backend": backend_metadata or {},
469 "data_status": data.status,
470 "phase": phase,
471 "interesting_origin": data.interesting_origin,
472 "choice_nodes": data.nodes if OBSERVABILITY_CHOICES else None,
473 "choice_spans": data.spans if OBSERVABILITY_CHOICES else None,
474 **_system_metadata(),
475 # unpack last so it takes precedence for duplicate keys
476 **(metadata or {}),
477 }
478 ),
479 run_start=run_start,
480 property=property,
481 )
482
483
484_WROTE_TO: set[Path] = set()
485_deliver_to_file_lock = Lock()
486
487
488def _deliver_to_file(
489 observation: Observation, thread_id: int
490) -> None: # pragma: no cover
491 from hypothesis.strategies._internal.utils import to_jsonable
492
493 kind = "testcases" if observation.type == "test_case" else "info"
494 observed_dir = storage_directory("observed")
495 observed_dir.create_if_missing()
496 observation_p = observed_dir.path / f"{date.today().isoformat()}_{kind}.jsonl"
497
498 observation_bytes = (
499 json.dumps(to_jsonable(observation, avoid_realization=False)) + "\n"
500 )
501 # only allow one conccurent file write to avoid write races. This is likely to make
502 # HYPOTHESIS_EXPERIMENTAL_OBSERVABILITY quite slow under threading. A queue
503 # would be an improvement, but that requires a background thread, and I
504 # would prefer to avoid a thread in the single-threaded case. We could
505 # switch over to a queue if we detect multithreading, but it's tricky to get
506 # right.
507 with _deliver_to_file_lock:
508 _WROTE_TO.add(observation_p)
509 with observation_p.open(mode="a") as f:
510 f.write(observation_bytes)
511
512
513_imported_at = time.time()
514
515
516@lru_cache
517def _system_metadata() -> dict[str, Any]:
518 return {
519 "sys_argv": sys.argv,
520 "os_getpid": os.getpid(),
521 "imported_at": _imported_at,
522 }
523
524
525#: If ``False``, do not collect coverage information when observability is enabled.
526#:
527#: This is exposed both for performance (as coverage collection can be slow on
528#: Python 3.11 and earlier) and size (if you do not use coverage information,
529#: you may not want to store it in-memory).
530OBSERVABILITY_COLLECT_COVERAGE = (
531 "HYPOTHESIS_EXPERIMENTAL_OBSERVABILITY_NOCOVER" not in os.environ
532)
533#: If ``True``, include the ``metadata.choice_nodes`` and ``metadata.spans`` keys
534#: in test case observations.
535#:
536#: ``False`` by default. ``metadata.choice_nodes`` and ``metadata.spans`` can be
537#: a substantial amount of data, and so must be opted-in to, even when
538#: observability is enabled.
539#:
540#: .. warning::
541#:
542#: EXPERIMENTAL AND UNSTABLE. We are actively working towards a better
543#: interface for this as of June 2025, and this attribute may disappear or
544#: be renamed without notice.
545#:
546OBSERVABILITY_CHOICES = "HYPOTHESIS_EXPERIMENTAL_OBSERVABILITY_CHOICES" in os.environ
547
548if OBSERVABILITY_COLLECT_COVERAGE is False and (
549 sys.version_info[:2] >= (3, 12)
550): # pragma: no cover
551 warnings.warn(
552 "Coverage data collection should be quite fast in Python 3.12 or later "
553 "so there should be no need to turn coverage reporting off.",
554 HypothesisWarning,
555 stacklevel=2,
556 )
557
558if (
559 "HYPOTHESIS_EXPERIMENTAL_OBSERVABILITY" in os.environ
560 or OBSERVABILITY_COLLECT_COVERAGE is False
561): # pragma: no cover
562 add_observability_callback(_deliver_to_file, all_threads=True)
563
564 # Remove files more than a week old, to cap the size on disk
565 max_age = (date.today() - timedelta(days=8)).isoformat()
566 for p in storage_directory("observed", intent_to_write=False).path.glob("*.jsonl"):
567 if p.stem < max_age: # pragma: no branch
568 p.unlink(missing_ok=True)