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.
@@ -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,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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.3
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [pytest11]
2
+ jevkit = jevkit_pytest.plugin