pytest-digline 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,15 @@
|
|
|
1
|
+
"""`pytest-digline`: the comparison, as rows in pytest's own report.
|
|
2
|
+
|
|
3
|
+
The plugin lives in `pytest_digline.plugin`, which is what the `pytest11` entry
|
|
4
|
+
point names. Nothing is exported here: a pytest plugin is not a library, and a
|
|
5
|
+
name importable from the package root is a surface somebody would write against.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from importlib.metadata import version as _distribution_version
|
|
9
|
+
|
|
10
|
+
# Read from the installed distribution rather than written here, for the reason
|
|
11
|
+
# `digline.__version__` is: a hand-written copy is neither derived nor gated,
|
|
12
|
+
# and `tests/test_versions.py` sweeps these sources for exactly that.
|
|
13
|
+
__version__ = _distribution_version("pytest-digline")
|
|
14
|
+
|
|
15
|
+
__all__ = ["__version__"]
|
pytest_digline/plugin.py
ADDED
|
@@ -0,0 +1,553 @@
|
|
|
1
|
+
"""The comparison, as rows in pytest's own report. (ADR 0013)
|
|
2
|
+
|
|
3
|
+
One item per check — one assertion on one case — because that is digline's own
|
|
4
|
+
unit of verdict. Anything coarser would fold several verdicts into one here, in
|
|
5
|
+
a front end, under a rule no record governs and no test in `digline.core` can
|
|
6
|
+
see.
|
|
7
|
+
|
|
8
|
+
Four states, and the fourth is the reason this is worth doing at all:
|
|
9
|
+
|
|
10
|
+
fine passed
|
|
11
|
+
worse FAILED
|
|
12
|
+
could not judge ERROR (raised in setup(): an exception in runtest()
|
|
13
|
+
is a failure whatever its type)
|
|
14
|
+
suspended SKIPPED with the reason the suite declared
|
|
15
|
+
|
|
16
|
+
The exit code cannot express a suspension — it never fails, so it disappears
|
|
17
|
+
into `0`. pytest has carried exactly that idea since it was written.
|
|
18
|
+
|
|
19
|
+
**Nothing here computes a number or composes a sentence.** Every score comes
|
|
20
|
+
from `digline.core`, every line from `digline.report`, every file from
|
|
21
|
+
`digline.host`. If that stops being true this package has become a fork of the
|
|
22
|
+
product with a nicer report, which is the thing ADR 0013 exists to prevent.
|
|
23
|
+
|
|
24
|
+
There is no promote surface. Not disabled, not gated: absent, the way it is
|
|
25
|
+
absent from `digline-mcp` (ADR 0011 §1), and `tests/test_no_promote.py` sweeps
|
|
26
|
+
these sources to keep it that way.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import sys
|
|
32
|
+
from collections.abc import Generator, Iterator, Sequence
|
|
33
|
+
from dataclasses import dataclass
|
|
34
|
+
from pathlib import Path
|
|
35
|
+
from typing import Any
|
|
36
|
+
|
|
37
|
+
import pytest
|
|
38
|
+
|
|
39
|
+
from digline.core import AssertionDelta, Comparison, Verdict, compare
|
|
40
|
+
from digline.host import (
|
|
41
|
+
UsageError,
|
|
42
|
+
git_commit,
|
|
43
|
+
load_suite,
|
|
44
|
+
load_target,
|
|
45
|
+
need_baseline,
|
|
46
|
+
read_artifacts,
|
|
47
|
+
read_run,
|
|
48
|
+
resolve_key,
|
|
49
|
+
utc_now_iso,
|
|
50
|
+
)
|
|
51
|
+
from digline.report import check_line, config_changes, headline
|
|
52
|
+
from digline.run import Suite, execute, planned_calls
|
|
53
|
+
from digline.store import FileResultStore
|
|
54
|
+
|
|
55
|
+
__all__: list[str] = []
|
|
56
|
+
|
|
57
|
+
#: The locale of everything this plugin prints. Fixed, and not a flag.
|
|
58
|
+
#:
|
|
59
|
+
#: `CLAUDE.md` draws the line between a *document* and a *terminal*: `report
|
|
60
|
+
#: --locale` is mandatory because a document has a recipient who did not choose
|
|
61
|
+
#: English, and `compare --locale` defaults to `en` like every other terminal
|
|
62
|
+
#: output. pytest's report is a terminal. `digline-mcp` made the same call for
|
|
63
|
+
#: the same reason — a caller who wants the customer's sentence renders the
|
|
64
|
+
#: report, which takes a mandatory locale.
|
|
65
|
+
LOCALE = "en"
|
|
66
|
+
|
|
67
|
+
#: The suite-scope name a run-level verdict carries, since it belongs to no case.
|
|
68
|
+
RUN_SCOPE = "<run>"
|
|
69
|
+
|
|
70
|
+
#: Set by `pytest_collection_modifyitems`, read by `pytest_terminal_summary`.
|
|
71
|
+
#: On the config's stash rather than a module global: a module global is shared
|
|
72
|
+
#: by every `Config` in the process, and `pytester` runs several in one.
|
|
73
|
+
HEADLINES: pytest.StashKey[list[str]] = pytest.StashKey()
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# --------------------------------------------------------------------------- #
|
|
77
|
+
# The options: pointing at the suite, and nothing else
|
|
78
|
+
# --------------------------------------------------------------------------- #
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def pytest_addoption(parser: pytest.Parser) -> None:
|
|
82
|
+
"""Two ways to name a suite and one flag that spends money. (ADR 0013 §5)
|
|
83
|
+
|
|
84
|
+
No threshold, no tolerance, no locale: the committed baseline is the only
|
|
85
|
+
reference this plugin has, and a flag that could move a bar would dissolve
|
|
86
|
+
that in the one place it is least visible — a CI invocation line.
|
|
87
|
+
|
|
88
|
+
An ini option rather than a `[tool.digline]` block because this *is* pytest
|
|
89
|
+
configuration: it lives where the rest of a project's pytest settings live,
|
|
90
|
+
and a reader who knows pytest knows where to look.
|
|
91
|
+
"""
|
|
92
|
+
group = parser.getgroup("digline", "digline: gate on a committed baseline")
|
|
93
|
+
group.addoption(
|
|
94
|
+
"--digline-suite",
|
|
95
|
+
action="append",
|
|
96
|
+
default=[],
|
|
97
|
+
metavar="PATH",
|
|
98
|
+
dest="digline_suite",
|
|
99
|
+
help="a digline suite to gate on; repeatable. Overrides digline_suites",
|
|
100
|
+
)
|
|
101
|
+
group.addoption(
|
|
102
|
+
"--digline-root",
|
|
103
|
+
default=None,
|
|
104
|
+
metavar="DIR",
|
|
105
|
+
dest="digline_root",
|
|
106
|
+
help="the perimeter holding .digline/ (default: pytest's rootdir)",
|
|
107
|
+
)
|
|
108
|
+
group.addoption(
|
|
109
|
+
"--digline-run",
|
|
110
|
+
action="store_true",
|
|
111
|
+
default=False,
|
|
112
|
+
dest="digline_run",
|
|
113
|
+
help=(
|
|
114
|
+
"run each suite before comparing. THIS CALLS YOUR PROVIDER AND "
|
|
115
|
+
"SPENDS MONEY: the planned call count is printed before the first "
|
|
116
|
+
"call. Without it, this plugin compares the latest stored run and "
|
|
117
|
+
"makes no network call at all"
|
|
118
|
+
),
|
|
119
|
+
)
|
|
120
|
+
parser.addini(
|
|
121
|
+
"digline_suites",
|
|
122
|
+
type="paths",
|
|
123
|
+
default=[],
|
|
124
|
+
help="digline suites this repository gates on, one per line",
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def pytest_configure(config: pytest.Config) -> None:
|
|
129
|
+
"""The one refusal, and it is here rather than at collection so that it
|
|
130
|
+
happens before anything can be called. (ADR 0013 §2)
|
|
131
|
+
|
|
132
|
+
`--collect-only` exists to list test names. A plugin that spent a hundred
|
|
133
|
+
model calls answering it would be the worst defect this package could ship,
|
|
134
|
+
and the person who types `--co` after `--digline-run` is always doing it to
|
|
135
|
+
find out what *would* happen.
|
|
136
|
+
"""
|
|
137
|
+
config.stash[HEADLINES] = []
|
|
138
|
+
if config.getoption("digline_run") and config.option.collectonly:
|
|
139
|
+
raise pytest.UsageError(
|
|
140
|
+
"--digline-run calls your provider and --collect-only exists to "
|
|
141
|
+
"list test names without running anything. Drop one of the two: "
|
|
142
|
+
"`--collect-only` alone lists the checks against the run already "
|
|
143
|
+
"stored, and costs nothing."
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def pytest_report_header(config: pytest.Config) -> str | None:
|
|
148
|
+
"""One line, and only when a suite was named. (ADR 0013 §8)"""
|
|
149
|
+
specs = _specs(config)
|
|
150
|
+
if not specs:
|
|
151
|
+
return None
|
|
152
|
+
how = "running then comparing" if config.getoption("digline_run") else "comparing"
|
|
153
|
+
return f"digline: {how} {len(specs)} suite(s) against the committed baseline"
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _specs(config: pytest.Config) -> Sequence[str]:
|
|
157
|
+
"""The suites to gate on. The command line wins, whole, over the ini.
|
|
158
|
+
|
|
159
|
+
Whole and not merged: a developer narrowing a run to one suite means that
|
|
160
|
+
suite instead of the configured set, and a flag that added to a list would
|
|
161
|
+
make narrowing impossible to express.
|
|
162
|
+
"""
|
|
163
|
+
given: list[str] = list(config.getoption("digline_suite") or [])
|
|
164
|
+
if given:
|
|
165
|
+
return given
|
|
166
|
+
return [str(path) for path in config.getini("digline_suites")]
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
# --------------------------------------------------------------------------- #
|
|
170
|
+
# Collection: the comparison, once per suite
|
|
171
|
+
# --------------------------------------------------------------------------- #
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
@pytest.hookimpl(tryfirst=True)
|
|
175
|
+
def pytest_collection_modifyitems(
|
|
176
|
+
session: pytest.Session, config: pytest.Config, items: list[pytest.Item]
|
|
177
|
+
) -> None:
|
|
178
|
+
"""Add this repository's checks to whatever pytest already collected.
|
|
179
|
+
|
|
180
|
+
**Inert when no suite is named** (ADR 0013 §8): `_specs` is empty, the loop
|
|
181
|
+
does not run, and a repository that has not asked for this plugin cannot
|
|
182
|
+
tell it is installed. That is not politeness — `uv sync --all-packages`
|
|
183
|
+
installs this package into digline's own development environment, so its
|
|
184
|
+
entry point is active for the suite that judges it.
|
|
185
|
+
|
|
186
|
+
`tryfirst` because `-k`, `-m` and `--last-failed` are themselves
|
|
187
|
+
implementations of this hook: items appended after they have run are items
|
|
188
|
+
they never saw, and selection would silently stop applying to exactly the
|
|
189
|
+
rows this plugin exists to add.
|
|
190
|
+
|
|
191
|
+
Injected here rather than by making the suite an initial path, which would
|
|
192
|
+
make pytest's own python plugin collect the file as a test module and
|
|
193
|
+
**import the user's suite a second time** — a module with an import-time
|
|
194
|
+
side effect would perform it twice.
|
|
195
|
+
"""
|
|
196
|
+
for spec in _specs(config):
|
|
197
|
+
opened = _open(spec, config)
|
|
198
|
+
node = _child(session, DiglineSuite, path=opened.path, opened=opened)
|
|
199
|
+
items.extend(node.collect())
|
|
200
|
+
config.stash[HEADLINES].append(opened.headline)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _child[N: pytest.Item | pytest.File](
|
|
204
|
+
parent: pytest.Collector, cls: type[N], **kwargs: Any
|
|
205
|
+
) -> N:
|
|
206
|
+
"""`cls.from_parent(parent, **kwargs)`, with the waiver in one place.
|
|
207
|
+
|
|
208
|
+
`from_parent` is pytest's constructor for a node — a node must never be
|
|
209
|
+
instantiated directly — and its signature is `(parent, **kw)`, so under
|
|
210
|
+
pyright strict every call site reports the type as partially unknown. That
|
|
211
|
+
is a fact about pytest's annotations and not about this code, so the waiver
|
|
212
|
+
is stated once here rather than four times where it would read as four
|
|
213
|
+
separate concessions.
|
|
214
|
+
"""
|
|
215
|
+
return cls.from_parent(parent, **kwargs) # pyright: ignore[reportUnknownMemberType]
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
@dataclass(frozen=True, slots=True)
|
|
219
|
+
class _Opened:
|
|
220
|
+
"""One suite, loaded once, compared once.
|
|
221
|
+
|
|
222
|
+
Loaded once for `digline-mcp`'s reason (ADR 0011): a suite is a Python file
|
|
223
|
+
this plugin executes, so loading it twice runs the user's module twice.
|
|
224
|
+
"""
|
|
225
|
+
|
|
226
|
+
path: Path
|
|
227
|
+
suite: Suite
|
|
228
|
+
comparison: Comparison
|
|
229
|
+
#: `temperature 0.3 → 0.7`, or empty. Composed once and handed to every row,
|
|
230
|
+
#: exactly as `summary_lines` composes it once for a whole listing.
|
|
231
|
+
coincides: str
|
|
232
|
+
headline: str
|
|
233
|
+
#: Whether this run carries the judge's own words. False on a redacted run,
|
|
234
|
+
#: where quoting a reason would be quoting something that is not there.
|
|
235
|
+
reasons_available: bool
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _open(spec: str, config: pytest.Config) -> _Opened:
|
|
239
|
+
"""Load, run if asked, compare. Every refusal on the way is a usage error.
|
|
240
|
+
|
|
241
|
+
`UsageError` becomes `pytest.UsageError`, which stops the session with
|
|
242
|
+
pytest's exit code 4 — the front end refusing the request that was made,
|
|
243
|
+
which is what digline's own `64` means (`AGENTS.md` §6). It is loud on
|
|
244
|
+
purpose: a suite with no baseline yet, or a path with a typo, must not
|
|
245
|
+
collect zero rows and let a green run mean "nothing to check". That is the
|
|
246
|
+
vacuously green assertion fixed decision 3 refuses.
|
|
247
|
+
"""
|
|
248
|
+
root = Path(config.getoption("digline_root") or config.rootpath)
|
|
249
|
+
try:
|
|
250
|
+
return _opened(spec, root, run_first=bool(config.getoption("digline_run")))
|
|
251
|
+
except UsageError as exc:
|
|
252
|
+
raise pytest.UsageError(f"digline: {exc}") from exc
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _opened(spec: str, root: Path, *, run_first: bool) -> _Opened:
|
|
256
|
+
suite, loaded = load_suite(spec, root=root)
|
|
257
|
+
path = _path_of(spec)
|
|
258
|
+
store = FileResultStore(str(root))
|
|
259
|
+
|
|
260
|
+
if run_first:
|
|
261
|
+
_measure(suite, loaded, spec, path, store, root=root)
|
|
262
|
+
|
|
263
|
+
key = resolve_key(store, suite, "latest").key
|
|
264
|
+
run = read_run(store, suite, key)
|
|
265
|
+
baseline = need_baseline(store, suite)
|
|
266
|
+
comparison = compare(run, baseline)
|
|
267
|
+
head = headline(comparison, run, baseline, locale=LOCALE)
|
|
268
|
+
return _Opened(
|
|
269
|
+
path=path,
|
|
270
|
+
suite=suite,
|
|
271
|
+
comparison=comparison,
|
|
272
|
+
coincides=config_changes(comparison.config_changes, LOCALE),
|
|
273
|
+
headline=head.sentence,
|
|
274
|
+
reasons_available=head.reasons_available,
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _measure(
|
|
279
|
+
suite: Suite,
|
|
280
|
+
loaded: Any,
|
|
281
|
+
spec: str,
|
|
282
|
+
path: Path,
|
|
283
|
+
store: FileResultStore,
|
|
284
|
+
*,
|
|
285
|
+
root: Path,
|
|
286
|
+
) -> None:
|
|
287
|
+
"""`--digline-run`: the run, and the count said out loud before it.
|
|
288
|
+
|
|
289
|
+
The sentence on stderr is `AGENTS.md` §7 and ADR 0006 §8, unchanged and not
|
|
290
|
+
optional. Sampling multiplies spend, the multiplication is the part that
|
|
291
|
+
surprises people, and a pytest invocation is a thing people run on a
|
|
292
|
+
keystroke.
|
|
293
|
+
|
|
294
|
+
The clock and git are read here and passed down as values, so the run is a
|
|
295
|
+
function of them rather than of when it happened to look.
|
|
296
|
+
"""
|
|
297
|
+
target = load_target(None, loaded, spec)
|
|
298
|
+
plan = planned_calls(suite)
|
|
299
|
+
print(f"digline: {plan.sentence()}", file=sys.stderr)
|
|
300
|
+
commit = git_commit(root)
|
|
301
|
+
created_at = utc_now_iso()
|
|
302
|
+
run = execute(
|
|
303
|
+
suite,
|
|
304
|
+
target,
|
|
305
|
+
created_at=created_at,
|
|
306
|
+
git_commit=commit,
|
|
307
|
+
artifacts=read_artifacts(suite, target, path.parent, root=root),
|
|
308
|
+
)
|
|
309
|
+
store.write_run(run)
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _path_of(spec: str) -> Path:
|
|
313
|
+
"""The file a spec names, dropping a trailing `:attribute`.
|
|
314
|
+
|
|
315
|
+
The loader's own rule (`host.loader._split`): only a trailing `:name`
|
|
316
|
+
counts, and only when `name` is an identifier — so a Windows path like
|
|
317
|
+
`C:\\suites\\qa.py` keeps its drive letter.
|
|
318
|
+
"""
|
|
319
|
+
head, sep, tail = spec.rpartition(":")
|
|
320
|
+
return Path(head if sep and tail.isidentifier() else spec).resolve()
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
# --------------------------------------------------------------------------- #
|
|
324
|
+
# The nodes
|
|
325
|
+
# --------------------------------------------------------------------------- #
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
class DiglineSuite(pytest.File):
|
|
329
|
+
"""One suite. Its children are its checks and its set-aside cases."""
|
|
330
|
+
|
|
331
|
+
def __init__(self, *, opened: _Opened, **kwargs: Any) -> None:
|
|
332
|
+
super().__init__(**kwargs) # pyright: ignore[reportUnknownMemberType]
|
|
333
|
+
self.opened = opened
|
|
334
|
+
|
|
335
|
+
def collect(self) -> Iterator[pytest.Item]:
|
|
336
|
+
for delta in self.opened.comparison.deltas:
|
|
337
|
+
yield _child(
|
|
338
|
+
self, Check, name=_name_of(delta), delta=delta, opened=self.opened
|
|
339
|
+
)
|
|
340
|
+
# Suspended cases come from the *run*, not from the comparison: a case
|
|
341
|
+
# set aside produces no verdicts, so it produces no deltas, so the
|
|
342
|
+
# comparison cannot report it at all. `SECTIONS` feeds the report's
|
|
343
|
+
# suspended section the same way, from the same asymmetry.
|
|
344
|
+
for case_id, reason in _suspensions(self.opened):
|
|
345
|
+
yield _child(self, Suspended, name=case_id, reason=reason)
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def _name_of(delta: AssertionDelta) -> str:
|
|
349
|
+
"""`how-do-i-return::llm_rubric`, or `<run>::precision`.
|
|
350
|
+
|
|
351
|
+
A run-level verdict belongs to no case and says so, rather than opening the
|
|
352
|
+
name with an empty field — the convention `summary_lines` already follows.
|
|
353
|
+
"""
|
|
354
|
+
return f"{delta.case_id if delta.scope == 'case' else RUN_SCOPE}::{delta.assertion}"
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _suspensions(opened: _Opened) -> Sequence[tuple[str, str]]:
|
|
358
|
+
"""The set-aside cases of the suite, with the reason each declared.
|
|
359
|
+
|
|
360
|
+
Read off the declared suite rather than off the run, so that a case
|
|
361
|
+
suspended *since* the stored run was produced is still reported as the
|
|
362
|
+
decision it is instead of vanishing between the two.
|
|
363
|
+
"""
|
|
364
|
+
return tuple(
|
|
365
|
+
(case.id, case.suspended)
|
|
366
|
+
for case in opened.suite.cases
|
|
367
|
+
if case.suspended is not None
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
class Check(pytest.Item):
|
|
372
|
+
"""One assertion on one case, in the state the comparison found it."""
|
|
373
|
+
|
|
374
|
+
def __init__(self, *, delta: AssertionDelta, opened: _Opened, **kwargs: Any):
|
|
375
|
+
super().__init__(**kwargs) # pyright: ignore[reportUnknownMemberType]
|
|
376
|
+
self.delta = delta
|
|
377
|
+
self.opened = opened
|
|
378
|
+
|
|
379
|
+
def setup(self) -> None:
|
|
380
|
+
"""The unjudged half, and it has to be here.
|
|
381
|
+
|
|
382
|
+
An exception raised in `runtest()` is a FAILURE whatever its type;
|
|
383
|
+
only setup and teardown produce pytest's error state. ADR 0001 §1 says
|
|
384
|
+
an error is neither green nor a regression, so a check that could not be
|
|
385
|
+
judged has to raise from here or stop being distinguishable.
|
|
386
|
+
|
|
387
|
+
Tested **after** the regression below, which is `exit_code()`'s
|
|
388
|
+
precedence per row: a check that both regressed and errored is reported
|
|
389
|
+
as the regression, because that is the louder fact.
|
|
390
|
+
"""
|
|
391
|
+
if self.delta.outcome == "regressed":
|
|
392
|
+
return
|
|
393
|
+
if _errored(self.delta.current):
|
|
394
|
+
raise CouldNotJudge(self)
|
|
395
|
+
|
|
396
|
+
def runtest(self) -> None:
|
|
397
|
+
if self.delta.outcome == "regressed":
|
|
398
|
+
raise GotWorse(self)
|
|
399
|
+
|
|
400
|
+
def reportinfo(self) -> tuple[Path, int | None, str]:
|
|
401
|
+
return self.path, None, self.name
|
|
402
|
+
|
|
403
|
+
def repr_failure(
|
|
404
|
+
self, excinfo: pytest.ExceptionInfo[BaseException], style: Any = None
|
|
405
|
+
) -> str: # noqa: E501, ARG002
|
|
406
|
+
"""The report's own sentence, and no traceback.
|
|
407
|
+
|
|
408
|
+
There is no Python frame worth showing: the exception was raised one
|
|
409
|
+
line from where the message was composed, and a stack trace through
|
|
410
|
+
`_pytest/runner.py` says nothing about a rubric score.
|
|
411
|
+
|
|
412
|
+
This covers the FAILED rows only — pytest consults `repr_failure` for
|
|
413
|
+
the `call` phase alone (`_pytest/reports.py:_format_failed_longrepr`).
|
|
414
|
+
The ERROR rows are covered by `pytest_runtest_makereport` below, and
|
|
415
|
+
both halves are needed: either alone leaves one of the two states
|
|
416
|
+
printing a traceback.
|
|
417
|
+
"""
|
|
418
|
+
return self.message()
|
|
419
|
+
|
|
420
|
+
def message(self) -> str:
|
|
421
|
+
"""What moved, in the words the report and `digline compare` use.
|
|
422
|
+
|
|
423
|
+
`check_line` is the report's own function (ADR 0013 §6). Composing this
|
|
424
|
+
sentence here instead would be a fourth prose rendering of one
|
|
425
|
+
comparison, bound to the other three by nothing.
|
|
426
|
+
"""
|
|
427
|
+
where = f"{self.delta.case_id or RUN_SCOPE} · {self.delta.assertion}"
|
|
428
|
+
line = check_line(self.delta, locale=LOCALE, coincides=self.opened.coincides)
|
|
429
|
+
parts = [f"digline: {where}", f" {line}"]
|
|
430
|
+
if self.opened.reasons_available and (reason := _reason(self.delta)):
|
|
431
|
+
parts.append(f" reason: {reason}")
|
|
432
|
+
return "\n".join(parts)
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
class Suspended(pytest.Item):
|
|
436
|
+
"""A case somebody set aside, with the reason they gave.
|
|
437
|
+
|
|
438
|
+
A decision rather than an outcome, which is why it is a skip and not a pass:
|
|
439
|
+
the run is smaller than the suite, and a reader has to be able to see that.
|
|
440
|
+
"""
|
|
441
|
+
|
|
442
|
+
def __init__(self, *, reason: str, **kwargs: Any):
|
|
443
|
+
super().__init__(**kwargs) # pyright: ignore[reportUnknownMemberType]
|
|
444
|
+
self.reason = reason
|
|
445
|
+
|
|
446
|
+
def runtest(self) -> None:
|
|
447
|
+
pytest.skip(self.reason)
|
|
448
|
+
|
|
449
|
+
def reportinfo(self) -> tuple[Path, int | None, str]:
|
|
450
|
+
return self.path, None, self.name
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
class DiglineOutcome(Exception):
|
|
454
|
+
"""Raised by an item about itself, and never printed as itself.
|
|
455
|
+
|
|
456
|
+
It carries the item rather than a string so that the message is composed
|
|
457
|
+
once, by `Check.message()`, whichever of the two phases the exception was
|
|
458
|
+
raised in.
|
|
459
|
+
"""
|
|
460
|
+
|
|
461
|
+
def __init__(self, item: Check) -> None:
|
|
462
|
+
super().__init__(item.name)
|
|
463
|
+
self.item = item
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
class GotWorse(DiglineOutcome):
|
|
467
|
+
"""A check that regressed against the approved reference."""
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
class CouldNotJudge(DiglineOutcome):
|
|
471
|
+
"""A check the suite could not judge. Neither green nor a regression."""
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def _errored(verdict: Verdict | None) -> bool:
|
|
475
|
+
"""Whether this check could not be judged.
|
|
476
|
+
|
|
477
|
+
**Not** `outcome == "errored"`, and the difference is a trap worth naming:
|
|
478
|
+
rule 1 of `compare()` classifies an absent counterpart as `new` before rule
|
|
479
|
+
2 can call it `errored`, so a case added since the baseline that fails on
|
|
480
|
+
its first day is `new` *and* unjudged. `report._summarized()` holds the
|
|
481
|
+
same rule, and the two must agree or the headline would count a case the
|
|
482
|
+
rows do not.
|
|
483
|
+
"""
|
|
484
|
+
return verdict is not None and verdict.status == "error"
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def _reason(delta: AssertionDelta) -> str:
|
|
488
|
+
"""The judge's own words, from whichever side of the comparison has them."""
|
|
489
|
+
source = delta.current if delta.current is not None else delta.baseline
|
|
490
|
+
return "" if source is None else source.reason
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
# --------------------------------------------------------------------------- #
|
|
494
|
+
# Reporting
|
|
495
|
+
# --------------------------------------------------------------------------- #
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
@pytest.hookimpl(wrapper=True)
|
|
499
|
+
def pytest_runtest_makereport(
|
|
500
|
+
item: pytest.Item, call: pytest.CallInfo[None]
|
|
501
|
+
) -> Generator[None, pytest.TestReport, pytest.TestReport]:
|
|
502
|
+
"""Keep the ERROR and SKIPPED rows honest, which `repr_failure` cannot.
|
|
503
|
+
|
|
504
|
+
Two things pytest does that this undoes for this plugin's items alone:
|
|
505
|
+
|
|
506
|
+
**A setup-phase exception prints a traceback.** `repr_failure` is consulted
|
|
507
|
+
for the `call` phase only; setup and teardown go through `_repr_failure_py`
|
|
508
|
+
with the `--tb` style. Since a check that could not be judged *must* raise
|
|
509
|
+
in setup to be an error at all, without this every ERROR row would carry a
|
|
510
|
+
stack trace through pytest's internals.
|
|
511
|
+
|
|
512
|
+
**A skip is located where `pytest.skip()` was called**, which is this file.
|
|
513
|
+
A reader looking at `SKIPPED [1] plugin.py:412` learns nothing; the suite
|
|
514
|
+
that declared the suspension is what they want.
|
|
515
|
+
"""
|
|
516
|
+
report = yield
|
|
517
|
+
if not isinstance(item, (Check, Suspended)):
|
|
518
|
+
return report
|
|
519
|
+
if isinstance(item, Check) and call.excinfo is not None:
|
|
520
|
+
if isinstance(call.excinfo.value, DiglineOutcome):
|
|
521
|
+
report.longrepr = item.message()
|
|
522
|
+
return report
|
|
523
|
+
if isinstance(item, Suspended) and report.skipped:
|
|
524
|
+
# `(path, line, reason)` is the shape pytest's own skip reports use, so
|
|
525
|
+
# `-rs` renders this like any other skip. The line is the file's first:
|
|
526
|
+
# the suspension is declared in the suite, and pointing at a line inside
|
|
527
|
+
# it would be a guess — a `.toml` suite reads its cases from a third
|
|
528
|
+
# file. What matters is that it names the suite and not this plugin.
|
|
529
|
+
report.longrepr = (str(item.path), 1, item.reason)
|
|
530
|
+
return report
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
def pytest_terminal_summary(
|
|
534
|
+
terminalreporter: pytest.TerminalReporter, exitstatus: int, config: pytest.Config
|
|
535
|
+
) -> None:
|
|
536
|
+
"""The headline sentence, once per suite. (ADR 0013 §6)
|
|
537
|
+
|
|
538
|
+
Once, and not stamped on every failing row: it is a statement about the
|
|
539
|
+
*run* — how many checks got worse, how many cases could not be judged,
|
|
540
|
+
whether the rules moved — and forty copies of one sentence is forty lines a
|
|
541
|
+
reader learns to skip, taking the row detail beside it.
|
|
542
|
+
|
|
543
|
+
It is `headline().sentence`, byte for byte the sentence `digline compare`
|
|
544
|
+
prints and the report shows, because a gate and a document must never say
|
|
545
|
+
two different things about one run.
|
|
546
|
+
"""
|
|
547
|
+
none: list[str] = []
|
|
548
|
+
sentences = config.stash.get(HEADLINES, none)
|
|
549
|
+
if not sentences:
|
|
550
|
+
return
|
|
551
|
+
terminalreporter.write_sep("=", "digline")
|
|
552
|
+
for sentence in sentences:
|
|
553
|
+
terminalreporter.write_line(sentence)
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: pytest-digline
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Gate a pytest run on a digline comparison: one row per check, against the baseline committed in your repo.
|
|
5
|
+
Project-URL: Homepage, https://digline.dev/
|
|
6
|
+
Project-URL: Documentation, https://digline.dev/product/pytest/
|
|
7
|
+
Project-URL: Changelog, https://digline.dev/product/changelog/
|
|
8
|
+
Project-URL: Repository, https://github.com/digline/digline
|
|
9
|
+
Project-URL: Issues, https://github.com/digline/digline/issues
|
|
10
|
+
Author-email: Alessandro Prandini <alessandro.prandini@ict-group.it>
|
|
11
|
+
License-Expression: Apache-2.0
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Framework :: Pytest
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Topic :: Software Development :: Testing
|
|
18
|
+
Classifier: Typing :: Typed
|
|
19
|
+
Requires-Python: >=3.12
|
|
20
|
+
Requires-Dist: digline>=0.9.0
|
|
21
|
+
Requires-Dist: pytest>=8.0
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# pytest-digline
|
|
25
|
+
|
|
26
|
+
The digline comparison, as rows in pytest's own report.
|
|
27
|
+
|
|
28
|
+
```sh
|
|
29
|
+
pip install pytest-digline
|
|
30
|
+
pytest --digline-suite eval/suite.py
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
```
|
|
34
|
+
digline: comparing 1 suite(s) against the committed baseline
|
|
35
|
+
|
|
36
|
+
eval/suite.py::alpha::contains .
|
|
37
|
+
eval/suite.py::alpha::llm_rubric F
|
|
38
|
+
eval/suite.py::beta::contains .
|
|
39
|
+
eval/suite.py::gamma s
|
|
40
|
+
|
|
41
|
+
=================================== FAILURES ===================================
|
|
42
|
+
digline: alpha · llm_rubric
|
|
43
|
+
dropped from 0.910000 to 0.640000, below its threshold of 0.700000,
|
|
44
|
+
and beyond the 0.880000–0.950000 this check measured across 5 samples
|
|
45
|
+
reason: signed=True, concise=False
|
|
46
|
+
|
|
47
|
+
==================================== digline ===================================
|
|
48
|
+
1 check got worse; every case could be judged; 1 case is suspended; the rules
|
|
49
|
+
are unchanged.
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
One row per **check** — one assertion on one case — because that is digline's
|
|
53
|
+
unit of verdict. `-k` and `--last-failed` select them like any other test.
|
|
54
|
+
|
|
55
|
+
## What it does, and what it costs
|
|
56
|
+
|
|
57
|
+
By default it **compares and never runs**: it reads the latest stored run of
|
|
58
|
+
each suite, holds it against the baseline committed in your repository, and
|
|
59
|
+
makes no network call at all. Producing a run stays a separate act —
|
|
60
|
+
`digline run`, the official image, the GitHub Action.
|
|
61
|
+
|
|
62
|
+
`--digline-run` runs each suite first. That calls your provider and spends
|
|
63
|
+
money, so it is a flag on the command line where the cost is visible, it prints
|
|
64
|
+
the planned call count before the first call, and it **refuses under
|
|
65
|
+
`--collect-only`**: a command whose job is to list test names must never be able
|
|
66
|
+
to spend a hundred model calls.
|
|
67
|
+
|
|
68
|
+
## The four states
|
|
69
|
+
|
|
70
|
+
| digline | pytest |
|
|
71
|
+
|---|---|
|
|
72
|
+
| fine | passed |
|
|
73
|
+
| got worse | **FAILED** |
|
|
74
|
+
| could not be judged | **ERROR** — an error is neither green nor a regression |
|
|
75
|
+
| suspended | **SKIPPED**, with the reason the suite declared |
|
|
76
|
+
|
|
77
|
+
The suspension is the one digline state an exit code cannot express: it never
|
|
78
|
+
fails, so it disappears into `0`. pytest has had a state for *a decision rather
|
|
79
|
+
than an outcome* since it was written, and this is it.
|
|
80
|
+
|
|
81
|
+
**What pytest cannot carry**: the process exit code. `digline compare` exits `1`
|
|
82
|
+
for a regression and `2` for a run it could not judge; a pytest run exits `1`
|
|
83
|
+
for either. The distinction survives in the report — `F` and `E` are counted
|
|
84
|
+
separately, `-rE` lists the errored rows — and in `--junit-xml`. A job that
|
|
85
|
+
needs `1` versus `2` runs `digline compare`, which is one command away.
|
|
86
|
+
|
|
87
|
+
## Naming a suite
|
|
88
|
+
|
|
89
|
+
Nothing is discovered by convention: a suite is a file that *executes*, and a
|
|
90
|
+
file found by convention is a file that runs by accident. Name it, either way:
|
|
91
|
+
|
|
92
|
+
```sh
|
|
93
|
+
pytest --digline-suite eval/suite.py --digline-suite eval/billing.toml
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
```ini
|
|
97
|
+
# pyproject.toml
|
|
98
|
+
[tool.pytest.ini_options]
|
|
99
|
+
digline_suites = ["eval/suite.py"]
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
The command line replaces the ini list rather than adding to it, so narrowing a
|
|
103
|
+
run to one suite is expressible. `--digline-root` names the perimeter holding
|
|
104
|
+
`.digline/` and defaults to pytest's rootdir.
|
|
105
|
+
|
|
106
|
+
Select rows with `-k`; a node id passed as an argument is not supported.
|
|
107
|
+
|
|
108
|
+
## What it will not do
|
|
109
|
+
|
|
110
|
+
**It does not promote.** There is no flag, no fixture and no marker that makes
|
|
111
|
+
a run the new baseline — not refused, *absent*, and a test in this package
|
|
112
|
+
sweeps the sources to keep it that way. A baseline is an approved reference;
|
|
113
|
+
the approval is a person's, and a green pytest run is the single most likely
|
|
114
|
+
place for a promotion to happen by accident.
|
|
115
|
+
|
|
116
|
+
**It has no thresholds of its own.** Every bar it reports against was declared
|
|
117
|
+
in your suite and frozen in the baseline somebody promoted and committed.
|
|
118
|
+
|
|
119
|
+
**It depends on digline and pytest, and nothing else.**
|
|
120
|
+
|
|
121
|
+
Turn it off for one run with `-p no:digline`. With no suite named it collects
|
|
122
|
+
nothing, prints nothing and adds no header.
|
|
123
|
+
|
|
124
|
+
The reasoning is [ADR 0013](https://digline.dev/product/adr/0013-the-pytest-plugin/).
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
pytest_digline/__init__.py,sha256=Lv14ybpemkuzaqQe3J5pOMZHhsulryJ2h13KLW8bzSk,689
|
|
2
|
+
pytest_digline/plugin.py,sha256=CwTG2rNHKd8P3d29R6Zu1wxBWTveE7uA_aPHmyrgTqU,22149
|
|
3
|
+
pytest_digline-0.1.0.dist-info/METADATA,sha256=aCceCNKKSYAKeCoTHTjPu6RJ_LfXumusrWAA0Hwbw1Y,4940
|
|
4
|
+
pytest_digline-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
5
|
+
pytest_digline-0.1.0.dist-info/entry_points.txt,sha256=g9VucnPj_vQaDjKdllnlf-ZPWrJPAJO1ojGgHGQJNsM,43
|
|
6
|
+
pytest_digline-0.1.0.dist-info/RECORD,,
|