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