jevkit-pytest 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- jevkit_pytest/__init__.py +13 -0
- jevkit_pytest/assertions.py +83 -0
- jevkit_pytest/cassette.py +204 -0
- jevkit_pytest/plugin.py +90 -0
- jevkit_pytest-0.1.0.dist-info/METADATA +103 -0
- jevkit_pytest-0.1.0.dist-info/RECORD +8 -0
- jevkit_pytest-0.1.0.dist-info/WHEEL +4 -0
- jevkit_pytest-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Record and replay TypeSafe Jev requests in pytest.
|
|
2
|
+
|
|
3
|
+
Cassettes are `.jevl` files, the same format the drift, bench and calibrate
|
|
4
|
+
packages read, so a recording made by your tests doubles as a golden set.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from .assertions import assert_answer, assert_confident, describe_answer
|
|
8
|
+
from .cassette import Cassette, CassetteMiss
|
|
9
|
+
|
|
10
|
+
__version__ = "0.1.0"
|
|
11
|
+
|
|
12
|
+
__all__ = ["Cassette", "CassetteMiss", "assert_answer", "assert_confident",
|
|
13
|
+
"describe_answer", "__version__"]
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Assertions over jev answers, with failure messages that say enough.
|
|
2
|
+
|
|
3
|
+
A bare ``assert answer.choice == "billing"`` tells you nothing about *how*
|
|
4
|
+
close the call was. These assertions print the distribution on failure, because
|
|
5
|
+
a 0.51/0.49 split and a 0.99/0.01 split are different bugs.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from jevkit_core import parse_answer
|
|
13
|
+
|
|
14
|
+
__all__ = ["assert_answer", "assert_confident", "describe_answer"]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def describe_answer(qid: str, raw: Any) -> str:
|
|
18
|
+
answer = parse_answer(qid, raw)
|
|
19
|
+
probs = ", ".join(
|
|
20
|
+
f"{k}={v:.3f}" for k, v in sorted(answer.probabilities.items(), key=lambda kv: -kv[1])
|
|
21
|
+
)
|
|
22
|
+
parts = [f"predicted={answer.predicted()!r}"]
|
|
23
|
+
if answer.confidence is not None:
|
|
24
|
+
parts.append(f"confidence={answer.confidence:.3f}")
|
|
25
|
+
if answer.score is not None:
|
|
26
|
+
parts.append(f"score={answer.score:.3f}")
|
|
27
|
+
return f"{qid}: {', '.join(parts)}\n probabilities: {probs}"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def assert_answer(
|
|
31
|
+
raw: Any,
|
|
32
|
+
expected: Any,
|
|
33
|
+
*,
|
|
34
|
+
question_id: str = "answer",
|
|
35
|
+
min_confidence: float | None = None,
|
|
36
|
+
min_probability: float | None = None,
|
|
37
|
+
) -> None:
|
|
38
|
+
"""Assert an answer selected ``expected``, optionally with enough certainty."""
|
|
39
|
+
answer = parse_answer(question_id, raw)
|
|
40
|
+
predicted = answer.predicted()
|
|
41
|
+
|
|
42
|
+
if not answer.is_correct(expected):
|
|
43
|
+
raise AssertionError(
|
|
44
|
+
f"expected {expected!r} but got {predicted!r}\n {describe_answer(question_id, raw)}"
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
if min_probability is not None:
|
|
48
|
+
actual = answer.probability_of(expected)
|
|
49
|
+
if actual < min_probability:
|
|
50
|
+
raise AssertionError(
|
|
51
|
+
f"{expected!r} was selected but carried only {actual:.3f} probability, "
|
|
52
|
+
f"below the required {min_probability:.3f}\n"
|
|
53
|
+
f" {describe_answer(question_id, raw)}"
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
if min_confidence is not None:
|
|
57
|
+
if answer.confidence is None:
|
|
58
|
+
raise AssertionError(
|
|
59
|
+
f"min_confidence was given but a {answer.type} answer carries no "
|
|
60
|
+
f"confidence. Use min_probability instead."
|
|
61
|
+
)
|
|
62
|
+
if answer.confidence < min_confidence:
|
|
63
|
+
raise AssertionError(
|
|
64
|
+
f"{expected!r} was selected but confidence was {answer.confidence:.3f}, "
|
|
65
|
+
f"below the required {min_confidence:.3f}\n"
|
|
66
|
+
f" {describe_answer(question_id, raw)}"
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def assert_confident(raw: Any, minimum: float, *, question_id: str = "answer") -> None:
|
|
71
|
+
"""Assert an answer is decisive, without caring which way it went.
|
|
72
|
+
|
|
73
|
+
Uses the API's confidence for Choice and Score. A Noul has none, so its
|
|
74
|
+
distance from 0.5 is used and the message says so.
|
|
75
|
+
"""
|
|
76
|
+
answer = parse_answer(question_id, raw)
|
|
77
|
+
value = answer.decisiveness
|
|
78
|
+
if value < minimum:
|
|
79
|
+
quantity = "decisiveness (|noul - 0.5| * 2)" if answer.type == "noul" else "confidence"
|
|
80
|
+
raise AssertionError(
|
|
81
|
+
f"{quantity} was {value:.3f}, below the required {minimum:.3f}\n"
|
|
82
|
+
f" {describe_answer(question_id, raw)}"
|
|
83
|
+
)
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"""Record and replay jev requests so tests do not hit the API.
|
|
2
|
+
|
|
3
|
+
A model call in a test is slow, costs money, needs a key in CI, and can change
|
|
4
|
+
its answer under you when the alias moves. The fix is the one VCR established
|
|
5
|
+
for HTTP: record real responses once, replay them forever, re-record on purpose.
|
|
6
|
+
|
|
7
|
+
A cassette is a `.jevl` file, the same format `drift`, `bench` and `calibrate`
|
|
8
|
+
read, so a recording made by your test suite is also a golden set you can replay
|
|
9
|
+
against the next model version.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import os
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any, Callable
|
|
17
|
+
|
|
18
|
+
from jevkit_core import Record, append_record, read_records, record_id
|
|
19
|
+
|
|
20
|
+
__all__ = ["Cassette", "CassetteMiss", "Mode"]
|
|
21
|
+
|
|
22
|
+
Mode = str # "replay" | "record" | "auto" | "passthrough"
|
|
23
|
+
|
|
24
|
+
_MODES = ("replay", "record", "auto", "passthrough")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class CassetteMiss(LookupError):
|
|
28
|
+
"""A request was not on the cassette and the mode forbids recording."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _response_to_parts(response: Any) -> tuple[str, dict[str, Any], dict[str, Any] | None]:
|
|
32
|
+
"""Pull (model, answers, usage) out of an SDK response or a plain dict."""
|
|
33
|
+
if isinstance(response, dict):
|
|
34
|
+
model, answers, usage = response.get("model", ""), response.get("answers", {}), response.get("usage")
|
|
35
|
+
else:
|
|
36
|
+
model = getattr(response, "model", "")
|
|
37
|
+
answers = getattr(response, "answers", {})
|
|
38
|
+
usage = getattr(response, "usage", None)
|
|
39
|
+
|
|
40
|
+
def plain(value: Any) -> Any:
|
|
41
|
+
if isinstance(value, dict):
|
|
42
|
+
return value
|
|
43
|
+
if hasattr(value, "model_dump"):
|
|
44
|
+
return value.model_dump()
|
|
45
|
+
if hasattr(value, "__dict__"):
|
|
46
|
+
return {k: v for k, v in vars(value).items() if not k.startswith("_")}
|
|
47
|
+
return value
|
|
48
|
+
|
|
49
|
+
return str(model), {qid: plain(a) for qid, a in (answers or {}).items()}, (
|
|
50
|
+
plain(usage) if usage is not None else None
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class ReplayedAnswer(dict):
|
|
55
|
+
"""A replayed answer.
|
|
56
|
+
|
|
57
|
+
Subclasses ``dict`` so ``answer["choice"]`` works, and mirrors the keys onto
|
|
58
|
+
attributes so ``answer.choice`` works too. Test code written against either
|
|
59
|
+
the SDK objects or the raw JSON keeps working without a shim.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
def __getattr__(self, name: str) -> Any:
|
|
63
|
+
try:
|
|
64
|
+
return self[name]
|
|
65
|
+
except KeyError as exc:
|
|
66
|
+
raise AttributeError(
|
|
67
|
+
f"answer has no field {name!r}; recorded fields are "
|
|
68
|
+
f"{', '.join(sorted(self)) or '(none)'}"
|
|
69
|
+
) from exc
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class ReplayedResponse:
|
|
73
|
+
"""What a cassette hands back in place of a live API response."""
|
|
74
|
+
|
|
75
|
+
def __init__(self, record: Record) -> None:
|
|
76
|
+
self._record = record
|
|
77
|
+
self.model = record.model
|
|
78
|
+
self.answers = {qid: ReplayedAnswer(a) for qid, a in record.answers.items()}
|
|
79
|
+
self.usage = record.usage
|
|
80
|
+
self.id = record.id
|
|
81
|
+
|
|
82
|
+
def __repr__(self) -> str: # pragma: no cover - debug aid
|
|
83
|
+
return f"ReplayedResponse(model={self.model!r}, answers={list(self.answers)})"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class Cassette:
|
|
87
|
+
"""A recorded set of jev requests, replayed by request digest.
|
|
88
|
+
|
|
89
|
+
``mode`` controls what happens on a miss:
|
|
90
|
+
|
|
91
|
+
- ``replay`` raise ``CassetteMiss``. The right default for CI.
|
|
92
|
+
- ``auto`` replay a hit, call through and append on a miss.
|
|
93
|
+
- ``record`` always call through and append, ignoring existing entries.
|
|
94
|
+
- ``passthrough`` never touch the cassette.
|
|
95
|
+
"""
|
|
96
|
+
|
|
97
|
+
def __init__(
|
|
98
|
+
self,
|
|
99
|
+
path: str | os.PathLike[str],
|
|
100
|
+
system_one: Callable[..., Any] | None = None,
|
|
101
|
+
*,
|
|
102
|
+
mode: Mode = "replay",
|
|
103
|
+
model: str = "jev-latest",
|
|
104
|
+
) -> None:
|
|
105
|
+
if mode not in _MODES:
|
|
106
|
+
raise ValueError(f"mode must be one of {_MODES}, got {mode!r}")
|
|
107
|
+
self.path = Path(path)
|
|
108
|
+
self.mode = mode
|
|
109
|
+
self.model = model
|
|
110
|
+
self._system_one = system_one
|
|
111
|
+
self._entries = self._load() if self.path.exists() and mode != "record" else {}
|
|
112
|
+
self.played: list[str] = []
|
|
113
|
+
self.recorded: list[str] = []
|
|
114
|
+
|
|
115
|
+
def _load(self) -> dict[str, Record]:
|
|
116
|
+
"""Index the file by *requested*-model digest.
|
|
117
|
+
|
|
118
|
+
A record stores the model that actually answered, which is what the
|
|
119
|
+
format requires: `jev-latest` is an alias and the response says
|
|
120
|
+
`jev-1.13.0`. But a test asks for the alias, so indexing on the answering
|
|
121
|
+
model would miss every lookup. The requested model is kept in `meta` at
|
|
122
|
+
record time and used to rebuild the index here.
|
|
123
|
+
|
|
124
|
+
Later entries win, so re-recording a request appends rather than
|
|
125
|
+
requiring a rewrite.
|
|
126
|
+
"""
|
|
127
|
+
entries: dict[str, Record] = {}
|
|
128
|
+
for record in read_records(self.path):
|
|
129
|
+
requested = str(record.meta.get("requested_model") or record.model)
|
|
130
|
+
entries[record_id(requested, record.state, record.questions)] = record
|
|
131
|
+
return entries
|
|
132
|
+
|
|
133
|
+
# -- introspection -----------------------------------------------------
|
|
134
|
+
|
|
135
|
+
def __len__(self) -> int:
|
|
136
|
+
return len(self._entries)
|
|
137
|
+
|
|
138
|
+
@property
|
|
139
|
+
def unplayed(self) -> list[str]:
|
|
140
|
+
"""Entries on the cassette that no test asked for.
|
|
141
|
+
|
|
142
|
+
Usually means a test was deleted or a question was reworded, leaving a
|
|
143
|
+
stale recording that will quietly rot.
|
|
144
|
+
"""
|
|
145
|
+
return sorted(set(self._entries) - set(self.played))
|
|
146
|
+
|
|
147
|
+
def contains(self, state: Any, questions: dict[str, Any], *, model: str | None = None) -> bool:
|
|
148
|
+
return record_id(model or self.model, state, questions) in self._entries
|
|
149
|
+
|
|
150
|
+
# -- the call ----------------------------------------------------------
|
|
151
|
+
|
|
152
|
+
def system_one(self, state: Any, questions: dict[str, Any], **kwargs: Any) -> Any:
|
|
153
|
+
"""Drop-in for a client's ``system_one``.
|
|
154
|
+
|
|
155
|
+
Signature matches what both official SDKs expose, so a cassette can be
|
|
156
|
+
passed anywhere a client is expected in a test.
|
|
157
|
+
"""
|
|
158
|
+
model = kwargs.pop("model", None) or self.model
|
|
159
|
+
|
|
160
|
+
if self.mode == "passthrough":
|
|
161
|
+
return self._call_through(state, questions, model=model, **kwargs)
|
|
162
|
+
|
|
163
|
+
key = record_id(model, state, questions)
|
|
164
|
+
|
|
165
|
+
if self.mode != "record":
|
|
166
|
+
hit = self._entries.get(key)
|
|
167
|
+
if hit is not None:
|
|
168
|
+
self.played.append(key)
|
|
169
|
+
return ReplayedResponse(hit)
|
|
170
|
+
|
|
171
|
+
if self.mode == "replay":
|
|
172
|
+
raise CassetteMiss(
|
|
173
|
+
f"no recording for this request on {self.path}.\n"
|
|
174
|
+
f" digest: {key}\n"
|
|
175
|
+
f" Re-run with mode='auto' (or --jev-record) to record it, and commit the "
|
|
176
|
+
f"updated cassette."
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
response = self._call_through(state, questions, model=model, **kwargs)
|
|
180
|
+
actual_model, answers, usage = _response_to_parts(response)
|
|
181
|
+
record = Record(
|
|
182
|
+
model=actual_model or model,
|
|
183
|
+
state=state,
|
|
184
|
+
questions=questions,
|
|
185
|
+
answers=answers,
|
|
186
|
+
usage=usage,
|
|
187
|
+
meta={"requested_model": model},
|
|
188
|
+
)
|
|
189
|
+
append_record(self.path, record)
|
|
190
|
+
self._entries[key] = record
|
|
191
|
+
# The live response is returned rather than the record: recording must
|
|
192
|
+
# not change what the code under test sees.
|
|
193
|
+
self.recorded.append(key)
|
|
194
|
+
return response
|
|
195
|
+
|
|
196
|
+
__call__ = system_one
|
|
197
|
+
|
|
198
|
+
def _call_through(self, state: Any, questions: dict[str, Any], **kwargs: Any) -> Any:
|
|
199
|
+
if self._system_one is None:
|
|
200
|
+
raise CassetteMiss(
|
|
201
|
+
f"cassette {self.path} is in mode {self.mode!r} and needs to call the API, "
|
|
202
|
+
f"but no client was supplied. Pass system_one= when constructing it."
|
|
203
|
+
)
|
|
204
|
+
return self._system_one(state, questions, **kwargs)
|
jevkit_pytest/plugin.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""The pytest plugin: fixtures and the ``--jev-record`` flag."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any, Iterator
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
from .assertions import assert_answer
|
|
11
|
+
from .cassette import Cassette
|
|
12
|
+
|
|
13
|
+
__all__ = ["jev_cassette", "jev_cassette_dir", "jev_client"]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def pytest_addoption(parser: pytest.Parser) -> None:
|
|
17
|
+
group = parser.getgroup("jev", "TypeSafe Jev cassettes")
|
|
18
|
+
group.addoption(
|
|
19
|
+
"--jev-record", action="store_true", default=False,
|
|
20
|
+
help="record any request missing from its cassette (mode 'auto')",
|
|
21
|
+
)
|
|
22
|
+
group.addoption(
|
|
23
|
+
"--jev-rerecord", action="store_true", default=False,
|
|
24
|
+
help="re-record every request, replacing the cassettes (mode 'record')",
|
|
25
|
+
)
|
|
26
|
+
group.addoption(
|
|
27
|
+
"--jev-cassette-dir", action="store", default=None,
|
|
28
|
+
help="where cassettes live (default: tests/cassettes next to the test file)",
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def pytest_configure(config: pytest.Config) -> None:
|
|
33
|
+
config.addinivalue_line("markers", "jev_cassette(name): use a named cassette file")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _mode(config: pytest.Config) -> str:
|
|
37
|
+
if config.getoption("--jev-rerecord"):
|
|
38
|
+
return "record"
|
|
39
|
+
if config.getoption("--jev-record"):
|
|
40
|
+
return "auto"
|
|
41
|
+
return "replay"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@pytest.fixture(scope="session")
|
|
45
|
+
def jev_cassette_dir(request: pytest.FixtureRequest) -> Path:
|
|
46
|
+
"""Directory holding cassettes. Override in a conftest to relocate them."""
|
|
47
|
+
configured = request.config.getoption("--jev-cassette-dir")
|
|
48
|
+
if configured:
|
|
49
|
+
return Path(configured)
|
|
50
|
+
return Path(str(request.config.rootpath)) / "tests" / "cassettes"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@pytest.fixture
|
|
54
|
+
def jev_client() -> Any:
|
|
55
|
+
"""The live client, used only when recording.
|
|
56
|
+
|
|
57
|
+
Override this in your conftest to return something with a ``system_one``
|
|
58
|
+
method. Left as ``None`` a replay-only run still works; a recording run
|
|
59
|
+
fails with a message saying exactly this.
|
|
60
|
+
"""
|
|
61
|
+
return None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@pytest.fixture
|
|
65
|
+
def jev_cassette(
|
|
66
|
+
request: pytest.FixtureRequest,
|
|
67
|
+
jev_cassette_dir: Path,
|
|
68
|
+
jev_client: Any,
|
|
69
|
+
) -> Iterator[Cassette]:
|
|
70
|
+
"""A cassette scoped to the current test.
|
|
71
|
+
|
|
72
|
+
The file is named after the test by default, so one test's recordings never
|
|
73
|
+
collide with another's. Override with ``@pytest.mark.jev_cassette("name")``
|
|
74
|
+
to share one cassette across several tests.
|
|
75
|
+
"""
|
|
76
|
+
marker = request.node.get_closest_marker("jev_cassette")
|
|
77
|
+
name = marker.args[0] if marker and marker.args else request.node.name
|
|
78
|
+
safe = "".join(c if c.isalnum() or c in "-_." else "_" for c in str(name))
|
|
79
|
+
|
|
80
|
+
jev_cassette_dir.mkdir(parents=True, exist_ok=True)
|
|
81
|
+
path = jev_cassette_dir / f"{safe}.jevl"
|
|
82
|
+
|
|
83
|
+
system_one = getattr(jev_client, "system_one", None) if jev_client is not None else None
|
|
84
|
+
cassette = Cassette(path, system_one, mode=_mode(request.config))
|
|
85
|
+
yield cassette
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# Re-exported so ``from jevkit_pytest import assert_answer`` works after the
|
|
89
|
+
# plugin is loaded as an entry point.
|
|
90
|
+
__all__ += ["assert_answer"]
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: jevkit-pytest
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Record and replay TypeSafe Jev requests in pytest. Cassettes are .jevl files, so a test recording doubles as a drift golden set.
|
|
5
|
+
Project-URL: Homepage, https://github.com/pjdurden/jevkit-py
|
|
6
|
+
Project-URL: Issues, https://github.com/pjdurden/jevkit-py/issues
|
|
7
|
+
Author: Prajjwal Chittori
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
Keywords: cassette,jev,pytest,system-one,testing,typesafe,vcr
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Framework :: Pytest
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Topic :: Software Development :: Testing
|
|
18
|
+
Requires-Python: >=3.10
|
|
19
|
+
Requires-Dist: jevkit-core>=0.2.0
|
|
20
|
+
Requires-Dist: pytest>=7.0
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
# jevkit-pytest
|
|
24
|
+
|
|
25
|
+
Record and replay TypeSafe Jev requests in pytest.
|
|
26
|
+
|
|
27
|
+
A model call in a test is slow, costs money, needs a key in CI, and can change
|
|
28
|
+
its answer under you when the alias moves. The fix is the one VCR established for
|
|
29
|
+
HTTP: record real responses once, replay them forever, re-record on purpose.
|
|
30
|
+
|
|
31
|
+
> Unofficial and unaffiliated with TypeSafe.
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
pip install jevkit-pytest
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Use
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
def test_routes_billing_questions(jev_cassette):
|
|
41
|
+
response = jev_cassette.system_one(
|
|
42
|
+
"I was charged twice for the same order",
|
|
43
|
+
{"team": {"type": "choice", "instructions": "Which team should handle this",
|
|
44
|
+
"criteria": {"billing": "Payment issues", "technical": "Bugs",
|
|
45
|
+
"unknown": "None apply"}}},
|
|
46
|
+
)
|
|
47
|
+
assert response.answers["team"]["choice"] == "billing"
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Record the first time, then never again:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
pytest --jev-record # record anything missing
|
|
54
|
+
pytest # replay only; a miss is a failure
|
|
55
|
+
pytest --jev-rerecord # replace every recording
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
To record you need a live client. Supply one by overriding the `jev_client`
|
|
59
|
+
fixture in your `conftest.py`:
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
import pytest
|
|
63
|
+
from typesafe_sdk import TypeSafeClient
|
|
64
|
+
|
|
65
|
+
@pytest.fixture
|
|
66
|
+
def jev_client():
|
|
67
|
+
with TypeSafeClient() as client:
|
|
68
|
+
yield client
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Replay-only runs need no client and no API key, which is the point: CI stays
|
|
72
|
+
green without a secret.
|
|
73
|
+
|
|
74
|
+
## Assertions that explain themselves
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
from jevkit_pytest import assert_answer, assert_confident
|
|
78
|
+
|
|
79
|
+
assert_answer(response.answers["team"], "billing", min_probability=0.6)
|
|
80
|
+
assert_confident(response.answers["urgency"], 0.8)
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
On failure these print the whole distribution, because a 0.51/0.49 split and a
|
|
84
|
+
0.99/0.01 split are different bugs and `assert x == y` cannot tell them apart.
|
|
85
|
+
|
|
86
|
+
`assert_confident` uses the API's confidence for Choice and Score. A Noul carries
|
|
87
|
+
none, so its distance from 0.5 is used and the message says which it used.
|
|
88
|
+
|
|
89
|
+
## Cassettes are golden sets
|
|
90
|
+
|
|
91
|
+
A cassette is a `.jevl` file, the same format `jevkit-drift`, `jevkit-bench` and
|
|
92
|
+
`jevkit-calibrate` read. So the recordings your tests already make are a golden
|
|
93
|
+
set you can replay against the next model version:
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
jevkit-drift tests/cassettes/test_routes_billing_questions.jevl candidate.jevl
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
That is the whole reason the format was defined before any of these packages.
|
|
100
|
+
|
|
101
|
+
## License
|
|
102
|
+
|
|
103
|
+
MIT
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
jevkit_pytest/__init__.py,sha256=2pyniclDDTNchuMAzNNXofGIrC8RBBjiEnBF8Cehw8c,473
|
|
2
|
+
jevkit_pytest/assertions.py,sha256=aWDpNZbQbAN8EiWheiIta3vhawgfb-OpKo6z3wd7skM,3111
|
|
3
|
+
jevkit_pytest/cassette.py,sha256=v2MdFO-pmK5T01T2AMWTtbT8ZCI7HLIS3anD05V9zT8,7678
|
|
4
|
+
jevkit_pytest/plugin.py,sha256=FlBhotK-a63LoPDmsuEnpNCqM4cB7ahnf7Y4d8ohMao,2998
|
|
5
|
+
jevkit_pytest-0.1.0.dist-info/METADATA,sha256=Yp8bZ1iYgg1Z6jJDWhadBndE0_j2xtwQ6Z3fxDxYZL4,3357
|
|
6
|
+
jevkit_pytest-0.1.0.dist-info/WHEEL,sha256=THafob7ofN-NsuMN7Mg4qZyHaQI7KkD-QlcQatYhXPo,87
|
|
7
|
+
jevkit_pytest-0.1.0.dist-info/entry_points.txt,sha256=__pcKgSgXicCqUTdicXJmwYgvlfLz2BXirAgG2naacs,41
|
|
8
|
+
jevkit_pytest-0.1.0.dist-info/RECORD,,
|