Skip to content

Commit 3990fbc

Browse files
authored
Merge pull request #4083 from Zac-HD/observe-backends
Allow alternative backends to provide observability metadata
2 parents f1efaf2 + 33446b8 commit 3990fbc

File tree

9 files changed

+132
-33
lines changed

9 files changed

+132
-33
lines changed

hypothesis-python/RELEASE.rst

+7
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
RELEASE_TYPE: minor
2+
3+
:ref:`alternative-backends` can now implement ``.observe_test_case()``
4+
and ``observe_information_message()`` methods, to record backend-specific
5+
metadata and messages in our :doc:`observability output <observability>`
6+
(:issue:`3845` and `hypothesis-crosshair#22
7+
<https://github.com/pschanely/hypothesis-crosshair/issues/22>`__).

hypothesis-python/docs/schema_observations.json

+4-4
Original file line numberDiff line numberDiff line change
@@ -74,16 +74,16 @@
7474
"type": "object",
7575
"properties": {
7676
"type": {
77-
"enum": [ "info", "alert", "error"],
77+
"enum": ["info", "alert", "error"],
7878
"description": "A tag which labels this observation as general information to show the user. Hypothesis uses info messages to report statistics; alert or error messages can be provided by plugins."
7979
},
8080
"title": {
8181
"type": "string",
8282
"description": "The title of this message"
8383
},
8484
"content": {
85-
"type": "string",
86-
"description": "The body of the message. May use markdown."
85+
"type": ["string", "object"],
86+
"description": "The body of the message. Strings are presumed to be human-readable messages in markdown format; dictionaries may contain arbitrary information (as for test-case metadata)."
8787
},
8888
"property": {
8989
"type": "string",
@@ -94,7 +94,7 @@
9494
"description": "unix timestamp at which we started running this test function, so that later analysis can group test cases by run."
9595
}
9696
},
97-
"required": [ "type", "title", "content", "property", "run_start"],
97+
"required": ["type", "title", "content", "property", "run_start"],
9898
"additionalProperties": false
9999
}
100100
]

hypothesis-python/scripts/other-tests.sh

+1-1
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ if [ "$(python -c $'import platform, sys; print(sys.version_info.releaselevel ==
6767
$PYTEST tests/ghostwriter/
6868
pip uninstall -y black
6969

70-
if [ "$(python -c "import platform; print(platform.python_implementation() not in {'PyPy', 'GraalVM'})")" = "True" ] ; then
70+
if [ "$HYPOTHESIS_PROFILE" != "crosshair" ] && [ "$(python -c "import platform; print(platform.python_implementation() not in {'PyPy', 'GraalVM'})")" = "True" ] ; then
7171
$PYTEST tests/array_api tests/numpy
7272
fi
7373
fi

hypothesis-python/src/hypothesis/core.py

+33-10
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,11 @@
7777
get_type_hints,
7878
int_from_bytes,
7979
)
80-
from hypothesis.internal.conjecture.data import ConjectureData, Status
80+
from hypothesis.internal.conjecture.data import (
81+
ConjectureData,
82+
PrimitiveProvider,
83+
Status,
84+
)
8185
from hypothesis.internal.conjecture.engine import BUFFER_SIZE, ConjectureRunner
8286
from hypothesis.internal.conjecture.junkdrawer import (
8387
ensure_free_stackframes,
@@ -1132,10 +1136,28 @@ def _execute_once_for_engine(self, data: ConjectureData) -> None:
11321136
timing=self._timing_features,
11331137
coverage=tractable_coverage_report(trace) or None,
11341138
phase=phase,
1139+
backend_metadata=data.provider.observe_test_case(),
11351140
)
11361141
deliver_json_blob(tc)
1142+
for msg in data.provider.observe_information_messages(
1143+
lifetime="test_case"
1144+
):
1145+
self._deliver_information_message(**msg)
11371146
self._timing_features = {}
11381147

1148+
def _deliver_information_message(
1149+
self, *, type: str, title: str, content: Union[str, dict]
1150+
) -> None:
1151+
deliver_json_blob(
1152+
{
1153+
"type": type,
1154+
"run_start": self._start_timestamp,
1155+
"property": self.test_identifier,
1156+
"title": title,
1157+
"content": content,
1158+
}
1159+
)
1160+
11391161
def run_engine(self):
11401162
"""Run the test function many times, on database input and generated
11411163
input, using the Conjecture engine.
@@ -1160,15 +1182,16 @@ def run_engine(self):
11601182
# on different inputs.
11611183
runner.run()
11621184
note_statistics(runner.statistics)
1163-
deliver_json_blob(
1164-
{
1165-
"type": "info",
1166-
"run_start": self._start_timestamp,
1167-
"property": self.test_identifier,
1168-
"title": "Hypothesis Statistics",
1169-
"content": describe_statistics(runner.statistics),
1170-
}
1171-
)
1185+
if TESTCASE_CALLBACKS:
1186+
self._deliver_information_message(
1187+
type="info",
1188+
title="Hypothesis Statistics",
1189+
content=describe_statistics(runner.statistics),
1190+
)
1191+
for msg in (
1192+
p if isinstance(p := runner.provider, PrimitiveProvider) else p(None)
1193+
).observe_information_messages(lifetime="test_function"):
1194+
self._deliver_information_message(**msg)
11721195

11731196
if runner.call_count == 0:
11741197
return

hypothesis-python/src/hypothesis/internal/conjecture/data.py

+29-3
Original file line numberDiff line numberDiff line change
@@ -1186,6 +1186,14 @@ def as_result(self) -> "ConjectureResult":
11861186
BYTE_MASKS = [(1 << n) - 1 for n in range(8)]
11871187
BYTE_MASKS[0] = 255
11881188

1189+
_Lifetime: TypeAlias = Literal["test_case", "test_function"]
1190+
1191+
1192+
class _BackendInfoMsg(TypedDict):
1193+
type: str
1194+
title: str
1195+
content: Union[str, Dict[str, Any]]
1196+
11891197

11901198
class PrimitiveProvider(abc.ABC):
11911199
# This is the low-level interface which would also be implemented
@@ -1212,7 +1220,7 @@ class PrimitiveProvider(abc.ABC):
12121220
# lifetime can access the passed ConjectureData object.
12131221
#
12141222
# Non-hypothesis providers probably want to set a lifetime of test_function.
1215-
lifetime = "test_function"
1223+
lifetime: _Lifetime = "test_function"
12161224

12171225
# Solver-based backends such as hypothesis-crosshair use symbolic values
12181226
# which record operations performed on them in order to discover new paths.
@@ -1240,9 +1248,28 @@ def realize(self, value: T) -> T:
12401248
12411249
The returned value should be non-symbolic.
12421250
"""
1243-
12441251
return value
12451252

1253+
def observe_test_case(self) -> Dict[str, Any]:
1254+
"""Called at the end of the test case when observability mode is active.
1255+
1256+
The return value should be a non-symbolic json-encodable dictionary,
1257+
and will be included as `observation["metadata"]["backend"]`.
1258+
"""
1259+
return {}
1260+
1261+
def observe_information_messages(
1262+
self, *, lifetime: _Lifetime
1263+
) -> Iterable[_BackendInfoMsg]:
1264+
"""Called at the end of each test case and again at end of the test function.
1265+
1266+
Return an iterable of `{type: info/alert/error, title: str, content: str|dict}`
1267+
dictionaries to be delivered as individual information messages.
1268+
(Hypothesis adds the `run_start` timestamp and `property` name for you.)
1269+
"""
1270+
assert lifetime in ("test_case", "test_function")
1271+
yield from []
1272+
12461273
@abc.abstractmethod
12471274
def draw_boolean(
12481275
self,
@@ -1307,7 +1334,6 @@ class HypothesisProvider(PrimitiveProvider):
13071334
lifetime = "test_case"
13081335

13091336
def __init__(self, conjecturedata: Optional["ConjectureData"], /):
1310-
assert conjecturedata is not None
13111337
super().__init__(conjecturedata)
13121338

13131339
def draw_boolean(

hypothesis-python/src/hypothesis/internal/observability.py

+3-1
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
import warnings
1818
from datetime import date, timedelta
1919
from functools import lru_cache
20-
from typing import Callable, Dict, List, Optional
20+
from typing import Any, Callable, Dict, List, Optional
2121

2222
from hypothesis.configuration import storage_directory
2323
from hypothesis.errors import HypothesisWarning
@@ -42,6 +42,7 @@ def make_testcase(
4242
timing: Dict[str, float],
4343
coverage: Optional[Dict[str, List[int]]] = None,
4444
phase: Optional[str] = None,
45+
backend_metadata: Optional[Dict[str, Any]] = None,
4546
) -> dict:
4647
if data.interesting_origin:
4748
status_reason = str(data.interesting_origin)
@@ -74,6 +75,7 @@ def make_testcase(
7475
"metadata": {
7576
"traceback": getattr(data.extra_information, "_expected_traceback", None),
7677
"predicates": data._observability_predicates,
78+
"backend": backend_metadata or {},
7779
**_system_metadata(),
7880
},
7981
"coverage": coverage,

hypothesis-python/tests/common/utils.py

+11
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
from hypothesis.errors import HypothesisDeprecationWarning
1919
from hypothesis.internal.entropy import deterministic_PRNG
2020
from hypothesis.internal.floats import next_down
21+
from hypothesis.internal.observability import TESTCASE_CALLBACKS
2122
from hypothesis.internal.reflection import proxies
2223
from hypothesis.reporting import default, with_reporter
2324
from hypothesis.strategies._internal.core import from_type, register_type_strategy
@@ -232,6 +233,16 @@ def raises_warning(expected_warning, match=None):
232233
yield r
233234

234235

236+
@contextlib.contextmanager
237+
def capture_observations():
238+
ls = []
239+
TESTCASE_CALLBACKS.append(ls.append)
240+
try:
241+
yield ls
242+
finally:
243+
TESTCASE_CALLBACKS.remove(ls.append)
244+
245+
235246
# Specifies whether we can represent subnormal floating point numbers.
236247
# IEE-754 requires subnormal support, but it's often disabled anyway by unsafe
237248
# compiler options like `-ffast-math`. On most hardware that's even a global

hypothesis-python/tests/conjecture/test_alt_backend.py

+43-2
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,10 @@
1010

1111
import math
1212
import sys
13+
from collections.abc import Sequence
1314
from contextlib import contextmanager
1415
from random import Random
15-
from typing import Optional, Sequence
16+
from typing import Optional
1617

1718
import pytest
1819

@@ -31,6 +32,7 @@
3132
from hypothesis.internal.intervalsets import IntervalSet
3233

3334
from tests.common.debug import minimal
35+
from tests.common.utils import capture_observations
3436
from tests.conjecture.common import ir_nodes
3537

3638

@@ -358,7 +360,7 @@ def test_function(n):
358360

359361

360362
def test_flaky_with_backend():
361-
with temp_register_backend("trivial", TrivialProvider):
363+
with temp_register_backend("trivial", TrivialProvider), capture_observations():
362364

363365
calls = 0
364366

@@ -428,3 +430,42 @@ def test_function(data):
428430
assert n1 <= n2
429431

430432
test_function()
433+
434+
435+
class ObservableProvider(TrivialProvider):
436+
def observe_test_case(self):
437+
return {"msg_key": "some message", "data_key": [1, "2", {}]}
438+
439+
def observe_information_messages(self, *, lifetime):
440+
if lifetime == "test_case":
441+
yield {"type": "info", "title": "trivial-data", "content": {"k2": "v2"}}
442+
else:
443+
assert lifetime == "test_function"
444+
yield {"type": "alert", "title": "Trivial alert", "content": "message here"}
445+
yield {"type": "info", "title": "trivial-data", "content": {"k2": "v2"}}
446+
447+
448+
def test_custom_observations_from_backend():
449+
with (
450+
temp_register_backend("observable", ObservableProvider),
451+
capture_observations() as ls,
452+
):
453+
454+
@given(st.none())
455+
@settings(backend="observable", database=None)
456+
def test_function(_):
457+
pass
458+
459+
test_function()
460+
461+
assert len(ls) >= 3
462+
cases = [t["metadata"]["backend"] for t in ls if t["type"] == "test_case"]
463+
assert {"msg_key": "some message", "data_key": [1, "2", {}]} in cases
464+
465+
infos = [
466+
{k: v for k, v in t.items() if k in ("title", "content")}
467+
for t in ls
468+
if t["type"] != "test_case"
469+
]
470+
assert {"title": "Trivial alert", "content": "message here"} in infos
471+
assert {"title": "trivial-data", "content": {"k2": "v2"}} in infos

hypothesis-python/tests/cover/test_observability.py

+1-12
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,6 @@
88
# v. 2.0. If a copy of the MPL was not distributed with this file, You can
99
# obtain one at https://mozilla.org/MPL/2.0/.
1010

11-
import contextlib
12-
1311
import pytest
1412

1513
from hypothesis import (
@@ -23,23 +21,14 @@
2321
target,
2422
)
2523
from hypothesis.database import InMemoryExampleDatabase
26-
from hypothesis.internal.observability import TESTCASE_CALLBACKS
2724
from hypothesis.stateful import (
2825
RuleBasedStateMachine,
2926
invariant,
3027
rule,
3128
run_state_machine_as_test,
3229
)
3330

34-
35-
@contextlib.contextmanager
36-
def capture_observations():
37-
ls = []
38-
TESTCASE_CALLBACKS.append(ls.append)
39-
try:
40-
yield ls
41-
finally:
42-
TESTCASE_CALLBACKS.remove(ls.append)
31+
from tests.common.utils import capture_observations
4332

4433

4534
@seed("deterministic so we don't miss some combination of features")

0 commit comments

Comments
 (0)