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 status_reason_location: str | None
186 choice_nodes: tuple[ChoiceNode, ...] | None
187 choice_spans: Optional["Spans"]
188
189 def to_json(self) -> dict[str, Any]:
190 data = {
191 "traceback": self.traceback,
192 "reproduction_decorator": self.reproduction_decorator,
193 "notes": self.notes,
194 "predicates": self.predicates,
195 "backend": self.backend,
196 "sys.argv": self.sys_argv,
197 "os.getpid()": self.os_getpid,
198 "imported_at": self.imported_at,
199 "data_status": self.data_status,
200 "phase": self.phase,
201 "interesting_origin": self.interesting_origin,
202 "status_reason_location": self.status_reason_location,
203 "choice_nodes": (
204 None if self.choice_nodes is None else nodes_to_json(self.choice_nodes)
205 ),
206 "choice_spans": (
207 None
208 if self.choice_spans is None
209 else [
210 (
211 # span.label is an int, but cast to string to avoid conversion
212 # to float (and loss of precision) for large label values.
213 #
214 # The value of this label is opaque to consumers anyway, so its
215 # type shouldn't matter as long as it's consistent.
216 str(span.label),
217 span.start,
218 span.end,
219 span.discarded,
220 )
221 for span in self.choice_spans
222 ]
223 ),
224 }
225 # check that we didn't forget one
226 assert len(data) == len(dataclasses.fields(self))
227 return data
228
229
230@dataclass(slots=True, frozen=True)
231class BaseObservation:
232 type: Literal["test_case", "info", "alert", "error"]
233 property: str
234 run_start: float
235
236
237InfoObservationType = Literal["info", "alert", "error"]
238TestCaseStatus = Literal["gave_up", "passed", "failed"]
239
240
241@dataclass(slots=True, frozen=True)
242class InfoObservation(BaseObservation):
243 type: InfoObservationType
244 title: str
245 content: str | dict
246
247
248@dataclass(slots=True, frozen=True)
249class TestCaseObservation(BaseObservation):
250 __test__ = False # no! bad pytest!
251
252 type: Literal["test_case"]
253 status: TestCaseStatus
254 status_reason: str
255 representation: str
256 arguments: dict
257 how_generated: str
258 features: dict
259 coverage: dict[str, list[int]] | None
260 timing: dict[str, float]
261 metadata: ObservationMetadata
262
263
264def add_observability_callback(f: CallbackT, /, *, all_threads: bool = False) -> None:
265 """
266 Adds ``f`` as a callback for |observability|. ``f``
267 should accept one argument, which is an observation. Whenever Hypothesis
268 produces a new observation, it calls each callback with that observation.
269
270 If Hypothesis tests are being run from multiple threads, callbacks are tracked
271 per-thread. In other words, ``add_observability_callback(f)`` only adds ``f``
272 as an observability callback for observations produced on that thread.
273
274 If ``all_threads=True`` is passed, ``f`` will instead be registered as a
275 callback for all threads. This means it will be called for observations
276 generated by all threads, not just the thread which registered ``f`` as a
277 callback. In this case, ``f`` will be passed two arguments: the first is the
278 observation, and the second is the integer thread id from
279 :func:`python:threading.get_ident` where that observation was generated.
280
281 We recommend against registering ``f`` as a callback for both ``all_threads=True``
282 and the default ``all_threads=False``, due to unclear semantics with
283 |remove_observability_callback|.
284 """
285 if all_threads:
286 _callbacks_all_threads.append(cast(CallbackAllThreadsT, f))
287 return
288
289 thread_id = threading.get_ident()
290 if thread_id not in _callbacks:
291 _callbacks[thread_id] = []
292
293 _callbacks[thread_id].append(cast(CallbackThreadT, f))
294
295
296def remove_observability_callback(f: CallbackT, /) -> None:
297 """
298 Removes ``f`` from the |observability| callbacks.
299
300 If ``f`` is not in the list of observability callbacks, silently do nothing.
301
302 If running under multiple threads, ``f`` will only be removed from the
303 callbacks for this thread.
304 """
305 if f in _callbacks_all_threads:
306 _callbacks_all_threads.remove(f)
307
308 thread_id = threading.get_ident()
309 if thread_id not in _callbacks:
310 return
311
312 callbacks = _callbacks[thread_id]
313 if f in callbacks:
314 callbacks.remove(f)
315
316 if not callbacks:
317 del _callbacks[thread_id]
318
319
320def observability_enabled() -> bool:
321 """
322 Returns whether or not Hypothesis considers |observability|
323 to be enabled. Observability is enabled if there is at least one observability
324 callback present.
325
326 Callers might use this method to determine whether they should compute an
327 expensive representation that is only used under observability, for instance
328 by |alternative backends|.
329 """
330 return bool(_callbacks) or bool(_callbacks_all_threads)
331
332
333@contextmanager
334def with_observability_callback(
335 f: Callable[[Observation], None], /, *, all_threads: bool = False
336) -> Generator[None, None, None]:
337 """
338 A simple context manager which calls |add_observability_callback| on ``f``
339 when it enters and |remove_observability_callback| on ``f`` when it exits.
340 """
341 add_observability_callback(f, all_threads=all_threads)
342 try:
343 yield
344 finally:
345 remove_observability_callback(f)
346
347
348def deliver_observation(observation: Observation) -> None:
349 thread_id = threading.get_ident()
350
351 for callback in _callbacks.get(thread_id, []):
352 callback(observation)
353
354 for callback in _callbacks_all_threads:
355 callback(observation, thread_id)
356
357
358class _TestcaseCallbacks:
359 def __bool__(self):
360 self._note_deprecation()
361 return bool(_callbacks)
362
363 def _note_deprecation(self):
364 note_deprecation(
365 "hypothesis.internal.observability.TESTCASE_CALLBACKS is deprecated. "
366 "Replace TESTCASE_CALLBACKS.append with add_observability_callback, "
367 "TESTCASE_CALLBACKS.remove with remove_observability_callback, and "
368 "bool(TESTCASE_CALLBACKS) with observability_enabled().",
369 since="2025-08-01",
370 has_codemod=False,
371 )
372
373 def append(self, f):
374 self._note_deprecation()
375 add_observability_callback(f)
376
377 def remove(self, f):
378 self._note_deprecation()
379 remove_observability_callback(f)
380
381
382#: .. warning::
383#:
384#: Deprecated in favor of |add_observability_callback|,
385#: |remove_observability_callback|, and |observability_enabled|.
386#:
387#: |TESTCASE_CALLBACKS| remains a thin compatibility
388#: shim which forwards ``.append``, ``.remove``, and ``bool()`` to those
389#: three methods. It is not an attempt to be fully compatible with the previous
390#: ``TESTCASE_CALLBACKS = []``, so iteration or other usages will not work
391#: anymore. Please update to using the new methods instead.
392#:
393#: |TESTCASE_CALLBACKS| will eventually be removed.
394TESTCASE_CALLBACKS = _TestcaseCallbacks()
395
396
397def make_testcase(
398 *,
399 run_start: float,
400 property: str,
401 data: "ConjectureData",
402 how_generated: str,
403 representation: str = "<unknown>",
404 timing: dict[str, float],
405 arguments: dict | None = None,
406 coverage: dict[str, list[int]] | None = None,
407 phase: str | None = None,
408 backend_metadata: dict[str, Any] | None = None,
409 status: (
410 Union[TestCaseStatus, "Status"] | None
411 ) = None, # overrides automatic calculation
412 status_reason: str | None = None, # overrides automatic calculation
413 # added to calculated metadata. If keys overlap, the value from this `metadata`
414 # is used
415 metadata: dict[str, Any] | None = None,
416) -> TestCaseObservation:
417 from hypothesis.core import reproduction_decorator
418 from hypothesis.internal.conjecture.data import Status
419
420 # We should only be sending observability reports for datas that have finished
421 # being modified.
422 assert data.frozen
423
424 if status_reason is not None:
425 pass
426 elif data.interesting_origin:
427 status_reason = str(data.interesting_origin)
428 elif phase == "shrink" and data.status == Status.OVERRUN:
429 status_reason = "exceeded size of current best test case"
430 elif data.status == Status.OVERRUN:
431 status_reason = (
432 str(data.events.pop("gave up because", ""))
433 or "exceeded maximum test case size"
434 )
435 else:
436 status_reason = str(data.events.pop("gave up because", ""))
437
438 status_map: dict[Status, TestCaseStatus] = {
439 Status.OVERRUN: "gave_up",
440 Status.INVALID: "gave_up",
441 Status.VALID: "passed",
442 Status.INTERESTING: "failed",
443 }
444
445 if status is not None and isinstance(status, Status):
446 status = status_map[status]
447 if status is None:
448 status = status_map[data.status]
449
450 status_reason_location: str | None = None
451 if (origin := data.interesting_origin) is not None:
452 if origin.filename is not None:
453 status_reason_location = f"{origin.filename}:{origin.lineno}"
454 elif status != "failed":
455 status_reason_location = data.invalid_location
456
457 return TestCaseObservation(
458 type="test_case",
459 status=status,
460 status_reason=status_reason,
461 representation=representation,
462 arguments={
463 k.removeprefix("generate:"): v for k, v in (arguments or {}).items()
464 },
465 how_generated=how_generated, # iid, mutation, etc.
466 features={
467 **{
468 f"target:{k}".strip(":"): v for k, v in data.target_observations.items()
469 },
470 **data.events,
471 },
472 coverage=coverage,
473 timing=timing,
474 metadata=ObservationMetadata(
475 **{
476 "traceback": data.expected_traceback,
477 "reproduction_decorator": (
478 reproduction_decorator(data.choices) if status == "failed" else None
479 ),
480 "notes": list(data.notes),
481 "predicates": dict(data._observability_predicates),
482 "backend": backend_metadata or {},
483 "data_status": data.status,
484 "phase": phase,
485 "interesting_origin": data.interesting_origin,
486 "status_reason_location": status_reason_location,
487 "choice_nodes": data.nodes if OBSERVABILITY_CHOICES else None,
488 "choice_spans": data.spans if OBSERVABILITY_CHOICES else None,
489 **_system_metadata(),
490 # unpack last so it takes precedence for duplicate keys
491 **(metadata or {}),
492 }
493 ),
494 run_start=run_start,
495 property=property,
496 )
497
498
499_WROTE_TO: set[Path] = set()
500_deliver_to_file_lock = Lock()
501
502
503def _deliver_to_file(
504 observation: Observation, thread_id: int
505) -> None: # pragma: no cover
506 from hypothesis.strategies._internal.utils import to_jsonable
507
508 kind = "testcases" if observation.type == "test_case" else "info"
509 observed_dir = storage_directory("observed")
510 observed_dir.create_if_missing()
511 observation_p = observed_dir.path / f"{date.today().isoformat()}_{kind}.jsonl"
512
513 observation_bytes = (
514 json.dumps(to_jsonable(observation, avoid_realization=False)) + "\n"
515 )
516 # only allow one conccurent file write to avoid write races. This is likely to make
517 # HYPOTHESIS_EXPERIMENTAL_OBSERVABILITY quite slow under threading. A queue
518 # would be an improvement, but that requires a background thread, and I
519 # would prefer to avoid a thread in the single-threaded case. We could
520 # switch over to a queue if we detect multithreading, but it's tricky to get
521 # right.
522 with _deliver_to_file_lock:
523 _WROTE_TO.add(observation_p)
524 with observation_p.open(mode="a") as f:
525 f.write(observation_bytes)
526
527
528_imported_at = time.time()
529
530
531@lru_cache
532def _system_metadata() -> dict[str, Any]:
533 return {
534 "sys_argv": sys.argv,
535 "os_getpid": os.getpid(),
536 "imported_at": _imported_at,
537 }
538
539
540#: If ``False``, do not collect coverage information when observability is enabled.
541#:
542#: This is exposed both for performance (as coverage collection can be slow on
543#: Python 3.11 and earlier) and size (if you do not use coverage information,
544#: you may not want to store it in-memory).
545OBSERVABILITY_COLLECT_COVERAGE = (
546 "HYPOTHESIS_EXPERIMENTAL_OBSERVABILITY_NOCOVER" not in os.environ
547)
548#: If ``True``, include the ``metadata.choice_nodes`` and ``metadata.spans`` keys
549#: in test case observations.
550#:
551#: ``False`` by default. ``metadata.choice_nodes`` and ``metadata.spans`` can be
552#: a substantial amount of data, and so must be opted-in to, even when
553#: observability is enabled.
554#:
555#: .. warning::
556#:
557#: EXPERIMENTAL AND UNSTABLE. We are actively working towards a better
558#: interface for this as of June 2025, and this attribute may disappear or
559#: be renamed without notice.
560#:
561OBSERVABILITY_CHOICES = "HYPOTHESIS_EXPERIMENTAL_OBSERVABILITY_CHOICES" in os.environ
562
563if OBSERVABILITY_COLLECT_COVERAGE is False and (
564 sys.version_info[:2] >= (3, 12)
565): # pragma: no cover
566 warnings.warn(
567 "Coverage data collection should be quite fast in Python 3.12 or later "
568 "so there should be no need to turn coverage reporting off.",
569 HypothesisWarning,
570 stacklevel=2,
571 )
572
573if (
574 "HYPOTHESIS_EXPERIMENTAL_OBSERVABILITY" in os.environ
575 or OBSERVABILITY_COLLECT_COVERAGE is False
576): # pragma: no cover
577 add_observability_callback(_deliver_to_file, all_threads=True)
578
579 # Remove files more than a week old, to cap the size on disk
580 max_age = (date.today() - timedelta(days=8)).isoformat()
581 for p in storage_directory("observed", intent_to_write=False).path.glob("*.jsonl"):
582 if p.stem < max_age: # pragma: no branch
583 p.unlink(missing_ok=True)