pytest-digline 0.2.0__tar.gz → 0.2.2__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: pytest-digline
3
- Version: 0.2.0
3
+ Version: 0.2.2
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/
@@ -17,7 +17,7 @@ Classifier: Programming Language :: Python :: 3.12
17
17
  Classifier: Topic :: Software Development :: Testing
18
18
  Classifier: Typing :: Typed
19
19
  Requires-Python: >=3.12
20
- Requires-Dist: digline>=0.19.0
20
+ Requires-Dist: digline>=0.20.0
21
21
  Requires-Dist: pytest>=8.0
22
22
  Description-Content-Type: text/markdown
23
23
 
@@ -84,6 +84,11 @@ for either. The distinction survives in the report — `F` and `E` are counted
84
84
  separately, `-rE` lists the errored rows — and in `--junit-xml`. A job that
85
85
  needs `1` versus `2` runs `digline compare`, which is one command away.
86
86
 
87
+ One exit code it does carry: a **refusal**. When digline declines the request
88
+ — no baseline, a document it refuses, a comparison across tenants or across
89
+ the projection — the session stops as pytest's usage error, exit `4`, and the
90
+ message names the refusal, as `digline compare` names it and exits `64`.
91
+
87
92
  ## Naming a suite
88
93
 
89
94
  Nothing is discovered by convention: a suite is a file that *executes*, and a
@@ -61,6 +61,11 @@ for either. The distinction survives in the report — `F` and `E` are counted
61
61
  separately, `-rE` lists the errored rows — and in `--junit-xml`. A job that
62
62
  needs `1` versus `2` runs `digline compare`, which is one command away.
63
63
 
64
+ One exit code it does carry: a **refusal**. When digline declines the request
65
+ — no baseline, a document it refuses, a comparison across tenants or across
66
+ the projection — the session stops as pytest's usage error, exit `4`, and the
67
+ message names the refusal, as `digline compare` names it and exits `64`.
68
+
64
69
  ## Naming a suite
65
70
 
66
71
  Nothing is discovered by convention: a suite is a file that *executes*, and a
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "pytest-digline"
3
- version = "0.2.0"
3
+ version = "0.2.2"
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"
@@ -38,7 +38,11 @@ dependencies = [
38
38
  # and reads `AssertionDelta.calibration` so a calibration case that moved
39
39
  # inside its band never fails a row. A calibration case outside its band is
40
40
  # the plugin's ERROR state, with the report's sentence. (ADR 0024 §4.7)
41
- "digline>=0.19.0",
41
+ #
42
+ # 0.20.0, a fourth time, for a name the gate sees: the plugin catches
43
+ # `digline.host.REFUSALS`, which arrived there, and turns every refusal into
44
+ # pytest's usage error rather than an INTERNALERROR.
45
+ "digline>=0.20.0",
42
46
  # A floor and no cap, unlike `digline-mcp`'s `mcp<3`. That cap records a
43
47
  # rename that actually happened; pytest has not moved the `pytest11` entry
44
48
  # point in a decade, and a cap here would fight the one dependency the user
@@ -39,7 +39,7 @@ import pytest
39
39
  if TYPE_CHECKING:
40
40
  from digline.core import AssertionDelta, Comparison, ScaleLost, Verdict
41
41
  from digline.run import Suite
42
- from digline.store import FileResultStore
42
+ from digline.store import ResultStore
43
43
 
44
44
  # **digline is imported inside the hooks, not here**, and the reason is
45
45
  # measured rather than tidy-minded.
@@ -76,6 +76,7 @@ RUN_SCOPE = "<run>"
76
76
  #: Set by `pytest_collection_modifyitems`, read by `pytest_terminal_summary`.
77
77
  #: On the config's stash rather than a module global: a module global is shared
78
78
  #: by every `Config` in the process, and `pytester` runs several in one.
79
+ #: Each suite's headline, followed by its `latest` note when there is one.
79
80
  HEADLINES: pytest.StashKey[list[str]] = pytest.StashKey()
80
81
 
81
82
 
@@ -204,6 +205,8 @@ def pytest_collection_modifyitems(
204
205
  node = _child(session, DiglineSuite, path=opened.path, opened=opened)
205
206
  items.extend(node.collect())
206
207
  config.stash[HEADLINES].append(opened.headline)
208
+ if opened.note:
209
+ config.stash[HEADLINES].append(f"note: {opened.note}")
207
210
 
208
211
 
209
212
  def _child[N: pytest.Item | pytest.File](
@@ -242,6 +245,11 @@ class _Opened:
242
245
  #: The calibration cases of the run that left their band, read off the run
243
246
  #: by the report's own function. (ADR 0024 §4.7)
244
247
  lost: Sequence[ScaleLost] = ()
248
+ #: What resolving `latest` stepped over, or empty. Its sharpest case is a
249
+ #: baseline promoted from a run newer than the one compared: every row is
250
+ #: then an older run held against a newer reference, and a red there is
251
+ #: not a regression. (#433)
252
+ note: str = ""
245
253
 
246
254
 
247
255
  def _open(spec: str, config: pytest.Config) -> _Opened:
@@ -253,14 +261,24 @@ def _open(spec: str, config: pytest.Config) -> _Opened:
253
261
  purpose: a suite with no baseline yet, or a path with a typo, must not
254
262
  collect zero rows and let a green run mean "nothing to check". That is the
255
263
  vacuously green assertion fixed decision 3 refuses.
264
+
265
+ **Every refusal in `digline.host.REFUSALS` takes the same road**, by
266
+ naming the tuple rather than its members, as `digline-mcp` does: a
267
+ document the store refuses, a comparison across tenants or across the
268
+ projection. Each is digline declining the request, and before this each
269
+ one reached pytest as `INTERNALERROR` and exit 3, which reads as a crash of
270
+ pytest or of this plugin. The class is named in the message, as the CLI
271
+ names it. (Delta-pass over 0.25.1)
256
272
  """
257
- from digline.host import UsageError
273
+ from digline.host import REFUSALS, UsageError
258
274
 
259
275
  root = Path(config.getoption("digline_root") or config.rootpath)
260
276
  try:
261
277
  return _opened(spec, root, run_first=bool(config.getoption("digline_run")))
262
278
  except UsageError as exc:
263
279
  raise pytest.UsageError(f"digline: {exc}") from exc
280
+ except REFUSALS as exc:
281
+ raise pytest.UsageError(f"digline: {type(exc).__name__}: {exc}") from exc
264
282
 
265
283
 
266
284
  def _opened(spec: str, root: Path, *, run_first: bool) -> _Opened:
@@ -276,8 +294,8 @@ def _opened(spec: str, root: Path, *, run_first: bool) -> _Opened:
276
294
  if run_first:
277
295
  _measure(suite, loaded, spec, path, store, root=root)
278
296
 
279
- key = resolve_key(store, suite, "latest").key
280
- run = read_run(store, suite, key)
297
+ resolved = resolve_key(store, suite, "latest")
298
+ run = read_run(store, suite, resolved.key)
281
299
  baseline = need_baseline(store, suite)
282
300
  comparison = compare(run, baseline)
283
301
  head = headline(comparison, run, baseline, locale=LOCALE)
@@ -289,6 +307,7 @@ def _opened(spec: str, root: Path, *, run_first: bool) -> _Opened:
289
307
  headline=head.sentence,
290
308
  reasons_available=head.reasons_available,
291
309
  lost=scale_lost(run),
310
+ note=resolved.note,
292
311
  )
293
312
 
294
313
 
@@ -297,7 +316,7 @@ def _measure(
297
316
  loaded: Any,
298
317
  spec: str,
299
318
  path: Path,
300
- store: FileResultStore,
319
+ store: ResultStore,
301
320
  *,
302
321
  root: Path,
303
322
  ) -> None:
@@ -655,6 +674,10 @@ def pytest_terminal_summary(
655
674
  It is `headline().sentence`, byte for byte the sentence `digline compare`
656
675
  prints and the report shows, because a gate and a document must never say
657
676
  two different things about one run.
677
+
678
+ Under it, the note `latest` left, as `note: …`, the line `digline compare`
679
+ prints on stderr. Here and not as a warning: under `-W error` a warning
680
+ fails the session, and a note is not a verdict. (#433)
658
681
  """
659
682
  none: list[str] = []
660
683
  sentences = config.stash.get(HEADLINES, none)
@@ -15,6 +15,7 @@ from __future__ import annotations
15
15
 
16
16
  from pathlib import Path
17
17
 
18
+ from digline.core import key_of
18
19
  from digline.host import load_suite, load_target, read_artifacts, utc_now_iso
19
20
  from digline.run import execute
20
21
  from digline.store import FileResultStore
@@ -36,7 +37,17 @@ def cycle(path: Path, root: Path, *, promote: bool) -> str:
36
37
  store = FileResultStore(str(root))
37
38
  ref = store.write_run(run)
38
39
  if promote:
40
+ # Whatever is there now: a cycle that promotes is standing in for a
41
+ # person who has just compared against it.
42
+ current = store.read_baseline(suite.tenant, suite.name)
39
43
  store.promote_baseline(
40
- ref, suite.config_hash(), promoted_at="2026-01-02T09:00:00+00:00"
44
+ ref,
45
+ suite.config_hash(),
46
+ expected_baseline=(
47
+ None
48
+ if current is None
49
+ else key_of(current.created_at, current.config_hash)
50
+ ),
51
+ promoted_at="2026-01-02T09:00:00+00:00",
41
52
  )
42
53
  return ref.key
@@ -141,3 +141,57 @@ def test_a_suite_that_does_not_exist_refuses_by_name(
141
141
  result = pytester.runpytest_subprocess("--digline-suite", "nope/suite.py")
142
142
  assert result.ret == pytest.ExitCode.USAGE_ERROR
143
143
  result.stderr.fnmatch_lines(["*no such file*"])
144
+
145
+
146
+ def _rewrite_baseline(root: Path, edit: Callable[[str], str]) -> None:
147
+ (stored,) = root.glob(".digline/*/baselines/*.json")
148
+ stored.write_text(edit(stored.read_text(encoding="utf-8")), encoding="utf-8")
149
+
150
+
151
+ def _projected(document: str) -> str:
152
+ import secrets
153
+
154
+ from digline.core import project, run_from_json, run_to_json
155
+
156
+ rows: dict[tuple[str, str], str] = {}
157
+ return run_to_json(
158
+ project(
159
+ run_from_json(document),
160
+ lambda kind, text: rows.setdefault((kind, text), secrets.token_urlsafe(16)),
161
+ )
162
+ )
163
+
164
+
165
+ def _unidentified(document: str) -> str:
166
+ import json
167
+
168
+ raw = json.loads(document)
169
+ raw["results"][0]["verdicts"][0]["assertion_id"] = ""
170
+ return json.dumps(raw)
171
+
172
+
173
+ @pytest.mark.parametrize(
174
+ ("edit", "named"),
175
+ [
176
+ pytest.param(_projected, "DifferentRegimesError", id="compare-refuses"),
177
+ pytest.param(_unidentified, "UnidentifiedVerdictError", id="read-refuses"),
178
+ ],
179
+ )
180
+ def test_a_refusal_from_digline_is_a_usage_error_not_a_crash(
181
+ pytester: pytest.Pytester,
182
+ baseline: Baseline,
183
+ edit: Callable[[str], str],
184
+ named: str,
185
+ ) -> None:
186
+ """Every refusal in `digline.host.REFUSALS` is the front end declining the
187
+ request, which is pytest's exit 4, as `UsageError` already was. Was:
188
+ `INTERNALERROR` and exit 3, which reads as a crash of pytest or of this
189
+ plugin. (Delta-pass over 0.25.1)"""
190
+ path = baseline(FINE, FINE)
191
+ _rewrite_baseline(pytester.path, edit)
192
+
193
+ result = pytester.runpytest_subprocess("--digline-suite", str(path))
194
+
195
+ assert result.ret == pytest.ExitCode.USAGE_ERROR
196
+ result.stderr.fnmatch_lines([f"*digline: {named}:*"])
197
+ assert "INTERNALERROR" not in result.stdout.str() + result.stderr.str()
@@ -0,0 +1,54 @@
1
+ """The note `latest` leaves, under the headline it qualifies. (#433)
2
+
3
+ This plugin compares `latest` against the baseline, and until #433 it took the
4
+ key and dropped the note. The case that matters: the baseline was promoted from
5
+ a run newer than `latest`, so every row holds an older run against a newer
6
+ reference, and a red row there is not a regression.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from collections.abc import Callable
12
+ from pathlib import Path
13
+
14
+ import pytest
15
+ from _cycle import cycle
16
+
17
+ FINE = {"alpha": "Acme, SATISFIED", "beta": "Acme, SATISFIED"}
18
+
19
+
20
+ def test_the_note_follows_the_headline_in_the_summary(
21
+ pytester: pytest.Pytester, repo: Callable[[dict[str, str]], Path]
22
+ ) -> None:
23
+ path = repo(FINE)
24
+ older = cycle(path, pytester.path, promote=False)
25
+ newer = cycle(path, pytester.path, promote=True)
26
+ (gone,) = (pytester.path / ".digline").glob(f"*/runs/**/{newer}.json")
27
+ gone.unlink()
28
+
29
+ # `-W error` too: the note is not a warning, so it cannot fail a session
30
+ # that turns warnings into errors.
31
+ result = pytester.runpytest_subprocess("--digline-suite", str(path), "-W", "error")
32
+
33
+ expected = (
34
+ f"note: the baseline was promoted from run {newer}, newer than {older}, "
35
+ "and that run was not read here"
36
+ )
37
+ text = result.stdout.str()
38
+ assert expected in text
39
+ assert text.index("= digline =") < text.index(expected)
40
+ assert result.ret == 0, text
41
+
42
+
43
+ def test_no_note_when_latest_steps_over_nothing(
44
+ pytester: pytest.Pytester, repo: Callable[[dict[str, str]], Path]
45
+ ) -> None:
46
+ """The control: a note on every session would be a line people learn to
47
+ skip, and would pass the test above."""
48
+ path = repo(FINE)
49
+ cycle(path, pytester.path, promote=True)
50
+ cycle(path, pytester.path, promote=False)
51
+
52
+ result = pytester.runpytest_subprocess("--digline-suite", str(path))
53
+
54
+ assert "note:" not in result.stdout.str()