pytest-digline 0.2.4__tar.gz → 0.3.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.
Files changed (27) hide show
  1. {pytest_digline-0.2.4 → pytest_digline-0.3.0}/PKG-INFO +18 -7
  2. {pytest_digline-0.2.4 → pytest_digline-0.3.0}/README.md +15 -5
  3. {pytest_digline-0.2.4 → pytest_digline-0.3.0}/pyproject.toml +20 -2
  4. {pytest_digline-0.2.4 → pytest_digline-0.3.0}/src/pytest_digline/plugin.py +242 -21
  5. {pytest_digline-0.2.4 → pytest_digline-0.3.0}/tests/_cycle.py +13 -2
  6. {pytest_digline-0.2.4 → pytest_digline-0.3.0}/tests/test_contract.py +6 -4
  7. {pytest_digline-0.2.4 → pytest_digline-0.3.0}/tests/test_message.py +1 -1
  8. pytest_digline-0.3.0/tests/test_same_suite_twice.py +146 -0
  9. pytest_digline-0.3.0/tests/test_session_not_green.py +215 -0
  10. pytest_digline-0.3.0/tests/test_spec_rule_mirrors_the_loader.py +78 -0
  11. pytest_digline-0.3.0/tests/test_suite_attribute_nodeid.py +225 -0
  12. pytest_digline-0.3.0/tests/test_summary_names.py +157 -0
  13. {pytest_digline-0.2.4 → pytest_digline-0.3.0}/.gitignore +0 -0
  14. {pytest_digline-0.2.4 → pytest_digline-0.3.0}/src/pytest_digline/__init__.py +0 -0
  15. {pytest_digline-0.2.4 → pytest_digline-0.3.0}/src/pytest_digline/py.typed +0 -0
  16. {pytest_digline-0.2.4 → pytest_digline-0.3.0}/tests/conftest.py +0 -0
  17. {pytest_digline-0.2.4 → pytest_digline-0.3.0}/tests/test_calibration_rows.py +0 -0
  18. {pytest_digline-0.2.4 → pytest_digline-0.3.0}/tests/test_cost.py +0 -0
  19. {pytest_digline-0.2.4 → pytest_digline-0.3.0}/tests/test_digline_run_notes.py +0 -0
  20. {pytest_digline-0.2.4 → pytest_digline-0.3.0}/tests/test_escapes.py +0 -0
  21. {pytest_digline-0.2.4 → pytest_digline-0.3.0}/tests/test_exits.py +0 -0
  22. {pytest_digline-0.2.4 → pytest_digline-0.3.0}/tests/test_incomparable_rows.py +0 -0
  23. {pytest_digline-0.2.4 → pytest_digline-0.3.0}/tests/test_inert.py +0 -0
  24. {pytest_digline-0.2.4 → pytest_digline-0.3.0}/tests/test_no_promote.py +0 -0
  25. {pytest_digline-0.2.4 → pytest_digline-0.3.0}/tests/test_plugin_one_declared_path.py +0 -0
  26. {pytest_digline-0.2.4 → pytest_digline-0.3.0}/tests/test_states.py +0 -0
  27. {pytest_digline-0.2.4 → pytest_digline-0.3.0}/tests/test_summary_note.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: pytest-digline
3
- Version: 0.2.4
3
+ Version: 0.3.0
4
4
  Summary: Gate a pytest run on a digline comparison: one row per check, against the baseline committed in your repo.
5
5
  Project-URL: Homepage, https://digline.dev/
6
6
  Project-URL: Documentation, https://digline.dev/product/pytest/
@@ -9,6 +9,7 @@ Project-URL: Repository, https://github.com/digline/digline
9
9
  Project-URL: Issues, https://github.com/digline/digline/issues
10
10
  Author-email: Alessandro Prandini <alessandro.prandini@ict-group.it>
11
11
  License-Expression: Apache-2.0
12
+ Keywords: baseline,evals,evaluation,llm,pytest,pytest-plugin,regression
12
13
  Classifier: Development Status :: 3 - Alpha
13
14
  Classifier: Framework :: Pytest
14
15
  Classifier: Intended Audience :: Developers
@@ -17,7 +18,7 @@ Classifier: Programming Language :: Python :: 3.12
17
18
  Classifier: Topic :: Software Development :: Testing
18
19
  Classifier: Typing :: Typed
19
20
  Requires-Python: >=3.12
20
- Requires-Dist: digline>=0.31.0
21
+ Requires-Dist: digline<0.33,>=0.32.0
21
22
  Requires-Dist: pytest>=8.0
22
23
  Description-Content-Type: text/markdown
23
24
 
@@ -44,8 +45,8 @@ digline: alpha · llm_rubric
44
45
  reason: signed=True, concise=False
45
46
 
46
47
  ==================================== digline ===================================
47
- 1 check got worse; every case could be judged; 1 case is suspended; the rules
48
- are unchanged.
48
+ eval/suite.py: 1 check got worse compared with the reference. Every case could
49
+ be judged. 1 case is suspended. The suite is unchanged from the reference.
49
50
  ```
50
51
 
51
52
  One row per **check** — one assertion on one case — because that is digline's
@@ -77,9 +78,19 @@ The suspension is the one digline state an exit code cannot express: it never
77
78
  fails, so it disappears into `0`. pytest has had a state for *a decision rather
78
79
  than an outcome* since it was written, and this is it.
79
80
 
80
- **What pytest cannot carry**: the process exit code. `digline compare` exits `1`
81
- for a regression and `2` for a run it could not judge; a pytest run exits `1`
82
- for either. The distinction survives in the report — `F` and `E` are counted
81
+ **A session digline did not say green does not end green.** When a suite's
82
+ comparison would make `digline compare` exit `1` or `2` and the session would
83
+ otherwise end `0`, it ends `1`, and the summary says why, one line per suite:
84
+ `eval/suite.py: digline exits 2, so this session does not end green.` That
85
+ covers what fails no row: a pinned file that drifted, and a run that judged
86
+ nothing because every case it measures is suspended. There is no flag. A
87
+ status that is already not `0` is left as it is. pytest's own summary line at
88
+ the bottom can stay green, because it counts the rows' outcomes and not the
89
+ exit status: read the exit code, and the digline line above it says why.
90
+
91
+ **What a pytest run does not carry**: the difference between digline's `1` and
92
+ `2`. A pytest run exits `1` for either, because pytest's `2` means interrupted.
93
+ The distinction survives in the report — `F` and `E` are counted
83
94
  separately, `-rE` lists the errored rows — and in `--junit-xml`. A job that
84
95
  needs `1` versus `2` runs `digline compare`, which is one command away.
85
96
 
@@ -21,8 +21,8 @@ digline: alpha · llm_rubric
21
21
  reason: signed=True, concise=False
22
22
 
23
23
  ==================================== digline ===================================
24
- 1 check got worse; every case could be judged; 1 case is suspended; the rules
25
- are unchanged.
24
+ eval/suite.py: 1 check got worse compared with the reference. Every case could
25
+ be judged. 1 case is suspended. The suite is unchanged from the reference.
26
26
  ```
27
27
 
28
28
  One row per **check** — one assertion on one case — because that is digline's
@@ -54,9 +54,19 @@ The suspension is the one digline state an exit code cannot express: it never
54
54
  fails, so it disappears into `0`. pytest has had a state for *a decision rather
55
55
  than an outcome* since it was written, and this is it.
56
56
 
57
- **What pytest cannot carry**: the process exit code. `digline compare` exits `1`
58
- for a regression and `2` for a run it could not judge; a pytest run exits `1`
59
- for either. The distinction survives in the report — `F` and `E` are counted
57
+ **A session digline did not say green does not end green.** When a suite's
58
+ comparison would make `digline compare` exit `1` or `2` and the session would
59
+ otherwise end `0`, it ends `1`, and the summary says why, one line per suite:
60
+ `eval/suite.py: digline exits 2, so this session does not end green.` That
61
+ covers what fails no row: a pinned file that drifted, and a run that judged
62
+ nothing because every case it measures is suspended. There is no flag. A
63
+ status that is already not `0` is left as it is. pytest's own summary line at
64
+ the bottom can stay green, because it counts the rows' outcomes and not the
65
+ exit status: read the exit code, and the digline line above it says why.
66
+
67
+ **What a pytest run does not carry**: the difference between digline's `1` and
68
+ `2`. A pytest run exits `1` for either, because pytest's `2` means interrupted.
69
+ The distinction survives in the report — `F` and `E` are counted
60
70
  separately, `-rE` lists the errored rows — and in `--junit-xml`. A job that
61
71
  needs `1` versus `2` runs `digline compare`, which is one command away.
62
72
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "pytest-digline"
3
- version = "0.2.4"
3
+ version = "0.3.0"
4
4
  description = "Gate a pytest run on a digline comparison: one row per check, against the baseline committed in your repo."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -8,6 +8,7 @@ license = "Apache-2.0"
8
8
  authors = [
9
9
  { name = "Alessandro Prandini", email = "alessandro.prandini@ict-group.it" },
10
10
  ]
11
+ keywords = ["pytest", "pytest-plugin", "llm", "evals", "evaluation", "regression", "baseline"]
11
12
  classifiers = [
12
13
  "Development Status :: 3 - Alpha",
13
14
  "Framework :: Pytest",
@@ -58,7 +59,24 @@ dependencies = [
58
59
  # §6.4). Against 0.30.0 the import raises `ImportError`. It was owed at the
59
60
  # cut and raised in this package's own release, after the core was on
60
61
  # PyPI, because a floor may not name a release that does not exist yet.
61
- "digline>=0.31.0",
62
+ #
63
+ # 0.32.0, a seventh time, by signature: opening each `--digline-suite`
64
+ # passes `declared=suite.declared()` to `headline`, and both the argument
65
+ # and `Suite.declared` first ship in 0.32.0, so the sentence can say that
66
+ # a run judged nothing (ADR 0049). Against 0.31.0 the call raises
67
+ # `AttributeError`. The summary also asks `phrase` for
68
+ # `fact.session_not_green`, a key new in 0.32.0 (ADR 0049 §7); against
69
+ # 0.31.0 that raises `KeyError`. It was owed at the cut and raised in this
70
+ # package's own release, after the core was on PyPI, because a floor may
71
+ # not name a release that does not exist yet.
72
+ #
73
+ # And a cap, `<0.33`, the first on the core. 0.32.0 made `declared` a
74
+ # mandatory argument of `headline`, and 0.2.4, which declared
75
+ # `digline>=0.31.0` and nothing above it, stopped every session on it with
76
+ # a `TypeError`. A core minor may change a signature this plugin calls, so
77
+ # the resolver keeps the core inside the minor this release was run
78
+ # against, and a user stays on a pair that works.
79
+ "digline>=0.32.0,<0.33",
62
80
  # A floor and no cap, unlike `digline-mcp`'s `mcp<3`. That cap records a
63
81
  # rename that actually happened; pytest has not moved the `pytest11` entry
64
82
  # point in a decade, and a cap here would fight the one dependency the user
@@ -30,6 +30,7 @@ from __future__ import annotations
30
30
 
31
31
  import sys
32
32
  import traceback
33
+ import warnings
33
34
  from collections.abc import Generator, Iterator, Sequence
34
35
  from dataclasses import dataclass
35
36
  from pathlib import Path
@@ -77,8 +78,10 @@ RUN_SCOPE = "<run>"
77
78
  #: Set by `pytest_collection_modifyitems`, read by `pytest_terminal_summary`.
78
79
  #: On the config's stash rather than a module global: a module global is shared
79
80
  #: by every `Config` in the process, and `pytester` runs several in one.
80
- #: Each suite's headline, followed by its `latest` note when there is one.
81
- HEADLINES: pytest.StashKey[list[str]] = pytest.StashKey()
81
+ #: Each named suite, by the nodeid of its `DiglineSuite` node, with what was
82
+ #: opened for it: the summary prints its headline and its `latest` note, each
83
+ #: under the path pytest prints for the suite's rows.
84
+ SUITES: pytest.StashKey[list[tuple[str, _Opened]]] = pytest.StashKey()
82
85
 
83
86
 
84
87
  # --------------------------------------------------------------------------- #
@@ -142,7 +145,7 @@ def pytest_configure(config: pytest.Config) -> None:
142
145
  and the person who types `--co` after `--digline-run` is always doing it to
143
146
  find out what *would* happen.
144
147
  """
145
- config.stash[HEADLINES] = []
148
+ config.stash[SUITES] = []
146
149
  if config.getoption("digline_run") and config.option.collectonly:
147
150
  raise pytest.UsageError(
148
151
  "--digline-run calls your provider and --collect-only exists to "
@@ -201,13 +204,155 @@ def pytest_collection_modifyitems(
201
204
  **import the user's suite a second time** — a module with an import-time
202
205
  side effect would perform it twice.
203
206
  """
204
- for spec in _specs(config):
207
+ #: The nodeid a file had before its suites carried their attribute,
208
+ #: mapped to the nodeids that replaced it: what a stale `--deselect` named.
209
+ renamed: dict[str, list[str]] = {}
210
+ specs = _specs(config)
211
+ _refuse_the_same_suite_twice(specs)
212
+ for spec in specs:
205
213
  opened = _open(spec, config)
206
- node = _child(session, DiglineSuite, path=opened.path, opened=opened)
214
+ node = _suite_node(session, spec, opened, renamed)
207
215
  items.extend(node.collect())
208
- config.stash[HEADLINES].append(opened.headline)
209
- if opened.note:
210
- config.stash[HEADLINES].append(f"note: {opened.note}")
216
+ config.stash[SUITES].append((node.nodeid, opened))
217
+ _warn_on_stale_deselects(config, renamed)
218
+
219
+
220
+ def _refuse_the_same_suite_twice(specs: Sequence[str]) -> None:
221
+ """Two specs that resolve to one suite are refused, before any suite loads.
222
+
223
+ Naming a suite twice asks for nothing: the same rows collected twice, two
224
+ identical `FAILED` lines, two identical summary lines (#566). Two suites
225
+ from one file are not one suite, and stay allowed (#571). Refused before
226
+ the first load, so that `--digline-run` has called no target by the time
227
+ the session stops. The specs are the user's own text, so the message passes
228
+ through `visible()`.
229
+ """
230
+ if len(specs) < 2:
231
+ # Inert with no suite named: digline is not imported (ADR 0013 §8).
232
+ return
233
+ from digline.report import visible
234
+
235
+ seen: dict[tuple[str, str | None], str] = {}
236
+ for spec in specs:
237
+ identity = _suite_identity(spec)
238
+ if identity in seen:
239
+ raise pytest.UsageError(
240
+ visible(
241
+ f"{seen[identity]!r} and {spec!r} name the same suite. A "
242
+ "suite is gated once in a session: name it once."
243
+ )
244
+ )
245
+ seen[identity] = spec
246
+
247
+
248
+ def _suite_identity(spec: str) -> tuple[str, str | None]:
249
+ """The suite a spec resolves to, by the loader's rule and without loading.
250
+
251
+ `host.loader.load_suite`: a `.toml` spec is a file and has no attribute. A
252
+ `file.py[:attribute]` spec, or one with a path separator, is a file
253
+ resolved from the working directory, with `SUITE_ATTR` when no attribute
254
+ is given. Anything else is a dotted module name, compared as written: which
255
+ file it imports is known only by importing it, and this runs before any
256
+ import, so `pkg.mod:suite` and `pkg/mod.py:suite` are not seen as one.
257
+
258
+ **A copy of the loader's rule, declared and pinned.** It mirrors
259
+ `host.loader.load_suite`, `_split` and the file-or-module test in
260
+ `_import`. If those change, this follows, and so do `_path_of` and
261
+ `_attribute_of`. `test_spec_rule_mirrors_the_loader.py` compares all
262
+ three with what the loader does, so a change there turns red rather than
263
+ leaving the copies to drift. A public function in `digline.host` would
264
+ remove the copy and raise this plugin's floor.
265
+ """
266
+ from digline.host import SUITE_ATTR, SUITE_SUFFIX
267
+
268
+ if spec.endswith(SUITE_SUFFIX):
269
+ return str(Path(spec).resolve()), None
270
+ head, sep, tail = spec.rpartition(":")
271
+ module_part, attribute = (
272
+ (head, tail) if sep and tail.isidentifier() else (spec, None)
273
+ )
274
+ if module_part.endswith(".py") or "/" in module_part or "\\" in module_part:
275
+ module_part = str(Path(module_part).resolve())
276
+ return module_part, attribute or SUITE_ATTR
277
+
278
+
279
+ def _suite_node(
280
+ session: pytest.Session, spec: str, opened: _Opened, renamed: dict[str, list[str]]
281
+ ) -> DiglineSuite:
282
+ """The suite's node, named by its attribute when the spec has one.
283
+
284
+ Two suites from one file, `both.py:support` and `both.py:billing`, would
285
+ otherwise be two nodes at one path with identical nodeids, and `-k`,
286
+ `--deselect` and `--lf` could not tell their rows apart (#566). The node
287
+ built first is the one pytest names from the path alone: its nodeid is
288
+ pytest's rendering of that path, which this does not restate, and the
289
+ attribute is appended to it.
290
+ """
291
+ node = _child(session, DiglineSuite, path=opened.path, opened=opened)
292
+ attribute = _attribute_of(spec)
293
+ if attribute is None:
294
+ return node
295
+ named = _child(
296
+ session,
297
+ DiglineSuite,
298
+ path=opened.path,
299
+ opened=opened,
300
+ nodeid=f"{node.nodeid}:{attribute}",
301
+ name=f"{node.name}:{attribute}",
302
+ )
303
+ renamed.setdefault(node.nodeid, []).append(named.nodeid)
304
+ return named
305
+
306
+
307
+ def _warn_on_stale_deselects(
308
+ config: pytest.Config, renamed: dict[str, list[str]]
309
+ ) -> None:
310
+ """One warning per `--deselect` value written for a nodeid that changed.
311
+
312
+ pytest deselects by prefix and says nothing about a prefix that matches
313
+ nothing, so a value naming a row by its old nodeid would let the row it
314
+ was meant to exclude run again in silence. The warning names, for that
315
+ value, the nodeids that replaced it. It does not honour the old form: it
316
+ says the form changed. A `PytestWarning`, so `-W error` stops the session,
317
+ which is what whoever passed it asked for, as a usage error. The value is
318
+ the user's own text, so it passes through `visible()` like every line this
319
+ plugin prints.
320
+ """
321
+ if not renamed:
322
+ # Inert unless a suite was named with an attribute: nothing to check,
323
+ # and digline is not imported for nothing (ADR 0013 §8).
324
+ return
325
+ from digline.report import visible
326
+
327
+ given: list[str] = list(config.getoption("deselect") or [])
328
+ for value in given:
329
+ replacements = [
330
+ new + value[len(old) :]
331
+ for old, news in renamed.items()
332
+ if value.startswith(f"{old}::")
333
+ for new in news
334
+ ]
335
+ if not replacements:
336
+ continue
337
+ stale = pytest.PytestWarning(
338
+ visible(
339
+ f"--deselect {value} names a row by the nodeid it had before a "
340
+ "suite named as file.py:attribute carried its attribute. It "
341
+ f"deselects nothing now. Use: {', '.join(replacements)}"
342
+ )
343
+ )
344
+ try:
345
+ # The warnings summary places this at this line, in the plugin.
346
+ # That is not a misplaced frame: there is no frame of the user's to
347
+ # place it in, because the cause is a value on the command line,
348
+ # not a call. No `stacklevel` reaches the user, so none is tried;
349
+ # the message carries the value and its replacements instead.
350
+ warnings.warn(stale, stacklevel=1)
351
+ except pytest.PytestWarning as exc:
352
+ # `-W error` turned it into an exception. Refused as a usage error,
353
+ # as every refusal of the request is here, and not let through as
354
+ # INTERNALERROR, which reads as a crash of pytest or of this plugin.
355
+ raise pytest.UsageError(str(exc)) from exc
211
356
 
212
357
 
213
358
  def _child[N: pytest.Item | pytest.File](
@@ -240,6 +385,10 @@ class _Opened:
240
385
  #: exactly as `summary_lines` composes it once for a whole listing.
241
386
  coincides: str
242
387
  headline: str
388
+ #: What digline exits with for this suite, `exit_code()` of the same
389
+ #: headline whose sentence is above: 0, 1 or 2, never a refusal, which
390
+ #: stops the session before an `_Opened` exists. (ADR 0049 §7)
391
+ exit_code: int
243
392
  #: Whether this run carries the judge's own words. False on a redacted run,
244
393
  #: where quoting a reason would be quoting something that is not there.
245
394
  reasons_available: bool
@@ -318,6 +467,7 @@ def _opened(spec: str, root: Path, *, run_first: bool) -> _Opened:
318
467
  from digline.host import load_suite, need_baseline, read_run, resolve_key
319
468
  from digline.report import config_changes, headline, scale_lost
320
469
  from digline.store import FileResultStore
470
+ from digline.wire import exit_code
321
471
 
322
472
  suite, loaded = load_suite(spec, root=root)
323
473
  path = _path_of(spec)
@@ -330,13 +480,14 @@ def _opened(spec: str, root: Path, *, run_first: bool) -> _Opened:
330
480
  run = read_run(store, suite, resolved.key)
331
481
  baseline = need_baseline(store, suite)
332
482
  comparison = compare(run, baseline)
333
- head = headline(comparison, run, baseline, locale=LOCALE)
483
+ head = headline(comparison, run, baseline, locale=LOCALE, declared=suite.declared())
334
484
  return _Opened(
335
485
  path=path,
336
486
  suite=suite,
337
487
  comparison=comparison,
338
488
  coincides=config_changes(comparison.config_changes, LOCALE),
339
489
  headline=head.sentence,
490
+ exit_code=exit_code(head),
340
491
  reasons_available=head.reasons_available,
341
492
  lost=scale_lost(run),
342
493
  note=resolved.note,
@@ -399,16 +550,26 @@ def _measure(
399
550
  # while being handed no pin set, so a front end that skipped this would
400
551
  # be refused rather than silently recording no control. (ADR 0029 §4)
401
552
  pinned=read_pinned(suite, path.parent, root=root, artifacts=artifacts),
553
+ # A test module importing the application the suite imports is the
554
+ # normal arrangement here, and the run says so. (ADR 0052, decision 2)
555
+ modules_unchecked=loaded.modules_unchecked,
402
556
  )
403
557
  store.write_run(run)
404
558
 
405
559
 
560
+ def _attribute_of(spec: str) -> str | None:
561
+ """The `:attribute` a spec ends with, by `_path_of`'s rule, or None."""
562
+ _head, sep, tail = spec.rpartition(":")
563
+ return tail if sep and tail.isidentifier() else None
564
+
565
+
406
566
  def _path_of(spec: str) -> Path:
407
567
  """The file a spec names, dropping a trailing `:attribute`.
408
568
 
409
569
  The loader's own rule (`host.loader._split`): only a trailing `:name`
410
570
  counts, and only when `name` is an identifier — so a Windows path like
411
- `C:\\suites\\qa.py` keeps its drive letter.
571
+ `C:\\suites\\qa.py` keeps its drive letter. A copy, pinned with
572
+ `_suite_identity`'s: see there.
412
573
  """
413
574
  head, sep, tail = spec.rpartition(":")
414
575
  return Path(head if sep and tail.isidentifier() else spec).resolve()
@@ -713,19 +874,79 @@ def pytest_terminal_summary(
713
874
  prints and the report shows, because a gate and a document must never say
714
875
  two different things about one run.
715
876
 
716
- Under it, the note `latest` left, as `note: …`, the line `digline compare`
717
- prints on stderr. Here and not as a warning: under `-W error` a warning
877
+ Each line starts with the path pytest prints for the suite's rows, so with
878
+ several suites a sentence is joined to its rows by name and not by order.
879
+
880
+ Under it, the note `latest` left, as `<path>: note: …`, the line `digline
881
+ compare` prints on stderr. Here and not as a warning: under `-W error` a warning
718
882
  fails the session, and a note is not a verdict. (#433)
719
883
  """
720
- none: list[str] = []
721
- sentences = config.stash.get(HEADLINES, none)
722
- if not sentences:
884
+ none: list[tuple[str, _Opened]] = []
885
+ suites = config.stash.get(SUITES, none)
886
+ if not suites:
723
887
  return
724
- from digline.report import visible
888
+ from digline.report import phrase, visible
889
+ from digline.wire import EXIT_OK
725
890
 
726
891
  terminalreporter.write_sep("=", "digline")
727
- for sentence in sentences:
728
- # Sanitised at the sink like every other line this plugin writes: the
729
- # sentence is built from a stored document — configuration values, an
730
- # artifact tally, a canary's case id. (0.10.1)
731
- terminalreporter.write_line(visible(sentence))
892
+ for nodeid, opened in suites:
893
+ # Named the way pytest names the suite's rows: its path relative to
894
+ # the directory pytest was started from, which is what
895
+ # `cwd_relative_nodeid` computes and what the progress lines and the
896
+ # short test summary print. A path, and not the suite's name: the name
897
+ # appears nowhere in pytest's report, and the path is the string the
898
+ # reader already has beside every row. Every line carries it, one
899
+ # suite or several, so a line is always joined to its rows by name.
900
+ # Two suites from one file are told apart by the attribute their
901
+ # nodeid carries, so the prefix carries it too: `both.py:support`.
902
+ prefix = config.cwd_relative_nodeid(nodeid)
903
+ lines = [opened.headline]
904
+ if opened.note:
905
+ lines.append(f"note: {opened.note}")
906
+ for line in lines:
907
+ # Sanitised at the sink like every other line this plugin writes:
908
+ # the sentence is built from a stored document — configuration
909
+ # values, an artifact tally, a canary's case id. (0.10.1)
910
+ terminalreporter.write_line(visible(f"{prefix}: {line}"))
911
+ if opened.exit_code != EXIT_OK:
912
+ # Why this session does not end green, for this suite: digline's
913
+ # code, in digline's words, named by the same prefix as the sentence
914
+ # that says the cause. (ADR 0049 §7)
915
+ terminalreporter.write_line(
916
+ visible(
917
+ phrase(
918
+ LOCALE,
919
+ "fact.session_not_green",
920
+ suite=prefix,
921
+ code=opened.exit_code,
922
+ )
923
+ )
924
+ )
925
+
926
+
927
+ @pytest.hookimpl(trylast=True)
928
+ def pytest_sessionfinish(session: pytest.Session) -> None:
929
+ """A session digline did not say green does not end green. (ADR 0049 §7)
930
+
931
+ When the session would end `0` and any suite's code is not `0`, it ends
932
+ `1`, pytest's *tests failed*: pytest's `2` is *interrupted*, which would be
933
+ false. One rule for every cause, because the code is `exit_code()`'s and
934
+ not a rule of this plugin's. A status that is already not `0` is left as
935
+ it is: `1`, `3`, `4`, `5` already say the session is not green.
936
+
937
+ `session.exitstatus` is read, not the `exitstatus` the hook is called with:
938
+ that argument is the same for every implementation, so it cannot show what
939
+ a plugin before this one set. `trylast` so that this reads after them.
940
+ pytest returns `session.exitstatus` after this hook (`wrap_session` in
941
+ `_pytest/main.py`, read at the locked pytest and at the floor this
942
+ package declares), so what is
943
+ written here is what the process exits with.
944
+ """
945
+ none: list[tuple[str, _Opened]] = []
946
+ suites = session.config.stash.get(SUITES, none)
947
+ if not suites or session.exitstatus != pytest.ExitCode.OK:
948
+ return
949
+ from digline.wire import EXIT_OK
950
+
951
+ if any(opened.exit_code != EXIT_OK for _, opened in suites):
952
+ session.exitstatus = pytest.ExitCode.TESTS_FAILED
@@ -16,7 +16,13 @@ from __future__ import annotations
16
16
  from pathlib import Path
17
17
 
18
18
  from digline.core import key_of
19
- from digline.host import load_suite, load_target, read_artifacts, utc_now_iso
19
+ from digline.host import (
20
+ load_suite,
21
+ load_target,
22
+ read_artifacts,
23
+ read_pinned,
24
+ utc_now_iso,
25
+ )
20
26
  from digline.run import execute
21
27
  from digline.store import FileResultStore
22
28
 
@@ -27,12 +33,16 @@ def cycle(path: Path, root: Path, *, promote: bool) -> str:
27
33
  spec = str(path)
28
34
  suite, loaded = load_suite(spec, root=root)
29
35
  target = load_target(None, loaded, spec)
36
+ artifacts = read_artifacts(suite, target, path.parent, root=root)
30
37
  run = execute(
31
38
  suite,
32
39
  target,
33
40
  created_at=utc_now_iso(),
34
41
  git_commit=None,
35
- artifacts=read_artifacts(suite, target, path.parent, root=root),
42
+ artifacts=artifacts,
43
+ # As `digline run` and `--digline-run` resolve them: a suite that pins
44
+ # a file is refused by `execute` without its resolved pin set.
45
+ pinned=read_pinned(suite, path.parent, root=root, artifacts=artifacts),
36
46
  )
37
47
  store = FileResultStore(str(root))
38
48
  ref = store.write_run(run)
@@ -49,5 +59,6 @@ def cycle(path: Path, root: Path, *, promote: bool) -> str:
49
59
  else key_of(current.created_at, current.config_hash)
50
60
  ),
51
61
  promoted_at="2026-01-02T09:00:00+00:00",
62
+ declared=None,
52
63
  )
53
64
  return ref.key
@@ -1,9 +1,11 @@
1
1
  """What survives the translation into pytest, and what does not. (ADR 0013 §7)
2
2
 
3
3
  The exit codes are digline's contract: `0` proceed, `1` stop and report what got
4
- worse, `2` stop — the run could not be judged. pytest cannot carry that
5
- distinction, and this file records the loss as a property rather than leaving it
6
- to be discovered by somebody building a gate on it.
4
+ worse, `2` stop — the run could not be judged. The plugin does not carry that
5
+ distinction into pytest's exit code. pytest would let it, so the loss is not a
6
+ limit of pytest, and whether the plugin should carry it is open (ADR 0013 §7,
7
+ note of 2026-10-13). This file records the loss as a property rather than
8
+ leaving it to be discovered by somebody building a gate on it.
7
9
  """
8
10
 
9
11
  from __future__ import annotations
@@ -29,7 +31,7 @@ def test_a_clean_comparison_exits_zero(
29
31
  def test_a_regression_and_an_unjudged_run_both_exit_one(
30
32
  pytester: pytest.Pytester, baseline: Baseline
31
33
  ) -> None:
32
- """The distinction digline draws and pytest erases.
34
+ """The distinction digline draws and the plugin does not carry.
33
35
 
34
36
  `digline compare` exits `1` for a regression and `2` for a run it could not
35
37
  judge. Here both are `1`, because pytest's process code says only that
@@ -102,7 +102,7 @@ def test_the_headline_is_printed_once_and_is_the_report_s(
102
102
  """
103
103
  path = baseline(FINE, {**FINE, "alpha": "Acme, unhappy", "beta": "Acme, unhappy"})
104
104
  comparison, run, base = _compared(path, pytester.path)
105
- sentence = headline(comparison, run, base, locale="en").sentence
105
+ sentence = headline(comparison, run, base, locale="en", declared=None).sentence
106
106
 
107
107
  result = pytester.runpytest_subprocess("--digline-suite", str(path))
108
108