jevkit-pytest 0.1.0__tar.gz

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.
@@ -0,0 +1,12 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ venv/
5
+ dist/
6
+ build/
7
+ *.egg-info/
8
+ .pytest_cache/
9
+ .coverage
10
+ htmlcov/
11
+ .ruff_cache/
12
+ uv.lock
@@ -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,81 @@
1
+ # jevkit-pytest
2
+
3
+ Record and replay TypeSafe Jev requests in pytest.
4
+
5
+ A model call in a test is slow, costs money, needs a key in CI, and can change
6
+ its answer under you when the alias moves. The fix is the one VCR established for
7
+ HTTP: record real responses once, replay them forever, re-record on purpose.
8
+
9
+ > Unofficial and unaffiliated with TypeSafe.
10
+
11
+ ```bash
12
+ pip install jevkit-pytest
13
+ ```
14
+
15
+ ## Use
16
+
17
+ ```python
18
+ def test_routes_billing_questions(jev_cassette):
19
+ response = jev_cassette.system_one(
20
+ "I was charged twice for the same order",
21
+ {"team": {"type": "choice", "instructions": "Which team should handle this",
22
+ "criteria": {"billing": "Payment issues", "technical": "Bugs",
23
+ "unknown": "None apply"}}},
24
+ )
25
+ assert response.answers["team"]["choice"] == "billing"
26
+ ```
27
+
28
+ Record the first time, then never again:
29
+
30
+ ```bash
31
+ pytest --jev-record # record anything missing
32
+ pytest # replay only; a miss is a failure
33
+ pytest --jev-rerecord # replace every recording
34
+ ```
35
+
36
+ To record you need a live client. Supply one by overriding the `jev_client`
37
+ fixture in your `conftest.py`:
38
+
39
+ ```python
40
+ import pytest
41
+ from typesafe_sdk import TypeSafeClient
42
+
43
+ @pytest.fixture
44
+ def jev_client():
45
+ with TypeSafeClient() as client:
46
+ yield client
47
+ ```
48
+
49
+ Replay-only runs need no client and no API key, which is the point: CI stays
50
+ green without a secret.
51
+
52
+ ## Assertions that explain themselves
53
+
54
+ ```python
55
+ from jevkit_pytest import assert_answer, assert_confident
56
+
57
+ assert_answer(response.answers["team"], "billing", min_probability=0.6)
58
+ assert_confident(response.answers["urgency"], 0.8)
59
+ ```
60
+
61
+ On failure these print the whole distribution, because a 0.51/0.49 split and a
62
+ 0.99/0.01 split are different bugs and `assert x == y` cannot tell them apart.
63
+
64
+ `assert_confident` uses the API's confidence for Choice and Score. A Noul carries
65
+ none, so its distance from 0.5 is used and the message says which it used.
66
+
67
+ ## Cassettes are golden sets
68
+
69
+ A cassette is a `.jevl` file, the same format `jevkit-drift`, `jevkit-bench` and
70
+ `jevkit-calibrate` read. So the recordings your tests already make are a golden
71
+ set you can replay against the next model version:
72
+
73
+ ```bash
74
+ jevkit-drift tests/cassettes/test_routes_billing_questions.jevl candidate.jevl
75
+ ```
76
+
77
+ That is the whole reason the format was defined before any of these packages.
78
+
79
+ ## License
80
+
81
+ MIT
@@ -0,0 +1,34 @@
1
+ [project]
2
+ name = "jevkit-pytest"
3
+ version = "0.1.0"
4
+ description = "Record and replay TypeSafe Jev requests in pytest. Cassettes are .jevl files, so a test recording doubles as a drift golden set."
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ license = "MIT"
8
+ authors = [{ name = "Prajjwal Chittori" }]
9
+ keywords = ["jev", "typesafe", "system-one", "pytest", "testing", "cassette", "vcr"]
10
+ classifiers = [
11
+ "Development Status :: 3 - Alpha",
12
+ "Framework :: Pytest",
13
+ "Intended Audience :: Developers",
14
+ "License :: OSI Approved :: MIT License",
15
+ "Programming Language :: Python :: 3.10",
16
+ "Programming Language :: Python :: 3.11",
17
+ "Programming Language :: Python :: 3.12",
18
+ "Topic :: Software Development :: Testing",
19
+ ]
20
+ dependencies = ["jevkit-core>=0.2.0", "pytest>=7.0"]
21
+
22
+ [project.entry-points.pytest11]
23
+ jevkit = "jevkit_pytest.plugin"
24
+
25
+ [project.urls]
26
+ Homepage = "https://github.com/pjdurden/jevkit-py"
27
+ Issues = "https://github.com/pjdurden/jevkit-py/issues"
28
+
29
+ [build-system]
30
+ requires = ["hatchling"]
31
+ build-backend = "hatchling.build"
32
+
33
+ [tool.hatch.build.targets.wheel]
34
+ packages = ["src/jevkit_pytest"]
@@ -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)
@@ -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,49 @@
1
+ import pytest
2
+ from jevkit_pytest import assert_answer, assert_confident, describe_answer
3
+
4
+ CHOICE = {"type": "choice", "choice": "billing",
5
+ "probabilities": {"billing": 0.55, "technical": 0.45}, "confidence": 0.3}
6
+ NOUL = {"type": "noul", "noul": 0.99}
7
+
8
+
9
+ def test_a_matching_answer_passes():
10
+ assert_answer(CHOICE, "billing")
11
+
12
+
13
+ def test_a_mismatch_reports_the_distribution():
14
+ with pytest.raises(AssertionError) as exc:
15
+ assert_answer(CHOICE, "technical")
16
+ assert "probabilities" in str(exc.value)
17
+ assert "billing=0.550" in str(exc.value)
18
+
19
+
20
+ def test_min_probability_catches_a_narrow_win():
21
+ assert_answer(CHOICE, "billing", min_probability=0.5)
22
+ with pytest.raises(AssertionError, match="carried only"):
23
+ assert_answer(CHOICE, "billing", min_probability=0.8)
24
+
25
+
26
+ def test_min_confidence_catches_a_low_confidence_win():
27
+ with pytest.raises(AssertionError, match="confidence was"):
28
+ assert_answer(CHOICE, "billing", min_confidence=0.8)
29
+
30
+
31
+ def test_min_confidence_on_a_noul_explains_the_alternative():
32
+ with pytest.raises(AssertionError, match="carries no\\s+confidence"):
33
+ assert_answer(NOUL, True, min_confidence=0.5)
34
+
35
+
36
+ def test_assert_confident_uses_decisiveness_for_a_noul():
37
+ assert_confident(NOUL, 0.9)
38
+ with pytest.raises(AssertionError, match="decisiveness"):
39
+ assert_confident({"type": "noul", "noul": 0.52}, 0.5)
40
+
41
+
42
+ def test_assert_confident_uses_confidence_for_a_choice():
43
+ with pytest.raises(AssertionError, match="confidence was"):
44
+ assert_confident(CHOICE, 0.9)
45
+
46
+
47
+ def test_describe_orders_probabilities_by_mass():
48
+ out = describe_answer("team", CHOICE)
49
+ assert out.index("billing=") < out.index("technical=")
@@ -0,0 +1,164 @@
1
+ import json
2
+ import pytest
3
+ from jevkit_core import Record, read_records
4
+ from jevkit_pytest import Cassette, CassetteMiss, assert_answer, assert_confident
5
+
6
+ QUESTIONS = {"team": {"type": "choice", "instructions": "Which team handles this"}}
7
+ STATE = "I was charged twice"
8
+
9
+ LIVE = {"model": "jev-1.13.0",
10
+ "answers": {"team": {"type": "choice", "choice": "billing",
11
+ "probabilities": {"billing": 0.9, "technical": 0.1},
12
+ "confidence": 0.8}},
13
+ "usage": {"input_tokens": 12}}
14
+
15
+
16
+ def client(calls):
17
+ def system_one(state, questions, **kw):
18
+ calls.append((state, questions))
19
+ return LIVE
20
+ return system_one
21
+
22
+
23
+ def test_replay_mode_raises_on_a_miss(tmp_path):
24
+ cassette = Cassette(tmp_path / "c.jevl", mode="replay")
25
+ with pytest.raises(CassetteMiss, match="no recording"):
26
+ cassette.system_one(STATE, QUESTIONS)
27
+
28
+
29
+ def test_auto_mode_records_then_replays(tmp_path):
30
+ path = tmp_path / "c.jevl"
31
+ calls = []
32
+
33
+ first = Cassette(path, client(calls), mode="auto")
34
+ first.system_one(STATE, QUESTIONS)
35
+ assert len(calls) == 1
36
+ assert path.exists()
37
+
38
+ second = Cassette(path, client(calls), mode="auto")
39
+ response = second.system_one(STATE, QUESTIONS)
40
+ assert len(calls) == 1, "second run must not call through"
41
+ assert response.answers["team"]["choice"] == "billing"
42
+
43
+
44
+ def test_recording_returns_the_live_response_unchanged(tmp_path):
45
+ cassette = Cassette(tmp_path / "c.jevl", client([]), mode="auto")
46
+ assert cassette.system_one(STATE, QUESTIONS) is LIVE
47
+
48
+
49
+ def test_replayed_answers_support_both_key_and_attribute_access(tmp_path):
50
+ path = tmp_path / "c.jevl"
51
+ Cassette(path, client([]), mode="auto").system_one(STATE, QUESTIONS)
52
+ response = Cassette(path, mode="replay").system_one(STATE, QUESTIONS)
53
+ assert response.answers["team"]["choice"] == "billing"
54
+ assert response.answers["team"].choice == "billing"
55
+ assert response.answers["team"].confidence == 0.8
56
+
57
+
58
+ def test_unknown_field_gives_a_helpful_attribute_error(tmp_path):
59
+ path = tmp_path / "c.jevl"
60
+ Cassette(path, client([]), mode="auto").system_one(STATE, QUESTIONS)
61
+ response = Cassette(path, mode="replay").system_one(STATE, QUESTIONS)
62
+ with pytest.raises(AttributeError, match="recorded fields are"):
63
+ _ = response.answers["team"].nonsense
64
+
65
+
66
+ def test_a_changed_question_is_a_miss(tmp_path):
67
+ path = tmp_path / "c.jevl"
68
+ Cassette(path, client([]), mode="auto").system_one(STATE, QUESTIONS)
69
+ reworded = {"team": {"type": "choice", "instructions": "Which department handles this"}}
70
+ with pytest.raises(CassetteMiss):
71
+ Cassette(path, mode="replay").system_one(STATE, reworded)
72
+
73
+
74
+ def test_record_mode_always_calls_through(tmp_path):
75
+ path = tmp_path / "c.jevl"
76
+ calls = []
77
+ Cassette(path, client(calls), mode="auto").system_one(STATE, QUESTIONS)
78
+ Cassette(path, client(calls), mode="record").system_one(STATE, QUESTIONS)
79
+ assert len(calls) == 2
80
+
81
+
82
+ def test_passthrough_never_writes(tmp_path):
83
+ path = tmp_path / "c.jevl"
84
+ Cassette(path, client([]), mode="passthrough").system_one(STATE, QUESTIONS)
85
+ assert not path.exists()
86
+
87
+
88
+ def test_recording_without_a_client_explains_itself(tmp_path):
89
+ cassette = Cassette(tmp_path / "c.jevl", mode="auto")
90
+ with pytest.raises(CassetteMiss, match="no client was supplied"):
91
+ cassette.system_one(STATE, QUESTIONS)
92
+
93
+
94
+ def test_invalid_mode_is_rejected(tmp_path):
95
+ with pytest.raises(ValueError, match="mode must be one of"):
96
+ Cassette(tmp_path / "c.jevl", mode="nonsense")
97
+
98
+
99
+ def test_unplayed_entries_are_reported(tmp_path):
100
+ path = tmp_path / "c.jevl"
101
+ Cassette(path, client([]), mode="auto").system_one(STATE, QUESTIONS)
102
+ cassette = Cassette(path, mode="replay")
103
+ assert len(cassette.unplayed) == 1
104
+ cassette.system_one(STATE, QUESTIONS)
105
+ assert cassette.unplayed == []
106
+
107
+
108
+ def test_a_cassette_is_a_valid_jevl_golden_set(tmp_path):
109
+ path = tmp_path / "c.jevl"
110
+ Cassette(path, client([]), mode="auto").system_one(STATE, QUESTIONS)
111
+ (record,) = list(read_records(path))
112
+ assert isinstance(record, Record)
113
+ assert record.model == "jev-1.13.0"
114
+ assert record.usage == {"input_tokens": 12}
115
+
116
+
117
+ def test_contains_reports_membership(tmp_path):
118
+ path = tmp_path / "c.jevl"
119
+ cassette = Cassette(path, client([]), mode="auto")
120
+ assert not cassette.contains(STATE, QUESTIONS)
121
+ cassette.system_one(STATE, QUESTIONS)
122
+ assert Cassette(path, mode="replay").contains(STATE, QUESTIONS)
123
+
124
+
125
+ def test_sdk_style_response_objects_are_recorded(tmp_path):
126
+ class Answer:
127
+ def __init__(self):
128
+ self.type, self.choice = "choice", "billing"
129
+ self.probabilities, self.confidence = {"billing": 1.0}, 0.9
130
+
131
+ class Response:
132
+ model = "jev-1.13.0"
133
+ answers = {"team": Answer()}
134
+ usage = None
135
+
136
+ path = tmp_path / "c.jevl"
137
+ Cassette(path, lambda s, q, **k: Response(), mode="auto").system_one(STATE, QUESTIONS)
138
+ (record,) = list(read_records(path))
139
+ assert record.answers["team"]["choice"] == "billing"
140
+
141
+
142
+ def test_recording_under_an_alias_replays_under_the_same_alias(tmp_path):
143
+ """Regression: the record stores the answering model (jev-1.13.0) while the
144
+ test asks for the alias (jev-latest). Indexing on the answering model made
145
+ every replay a miss."""
146
+ path = tmp_path / "c.jevl"
147
+ calls = []
148
+ Cassette(path, client(calls), mode="auto", model="jev-latest").system_one(STATE, QUESTIONS)
149
+
150
+ replayed = Cassette(path, client(calls), mode="auto", model="jev-latest")
151
+ replayed.system_one(STATE, QUESTIONS)
152
+ assert len(calls) == 1, "replay must not call through"
153
+
154
+ (record,) = list(read_records(path))
155
+ assert record.model == "jev-1.13.0", "the record states who actually answered"
156
+ assert record.meta["requested_model"] == "jev-latest"
157
+
158
+
159
+ def test_a_different_requested_model_is_a_separate_entry(tmp_path):
160
+ path = tmp_path / "c.jevl"
161
+ calls = []
162
+ Cassette(path, client(calls), mode="auto", model="jev-latest").system_one(STATE, QUESTIONS)
163
+ Cassette(path, client(calls), mode="auto", model="jev-1.14.0").system_one(STATE, QUESTIONS)
164
+ assert len(calls) == 2