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.
- {pytest_digline-0.2.0 → pytest_digline-0.2.2}/PKG-INFO +7 -2
- {pytest_digline-0.2.0 → pytest_digline-0.2.2}/README.md +5 -0
- {pytest_digline-0.2.0 → pytest_digline-0.2.2}/pyproject.toml +6 -2
- {pytest_digline-0.2.0 → pytest_digline-0.2.2}/src/pytest_digline/plugin.py +28 -5
- {pytest_digline-0.2.0 → pytest_digline-0.2.2}/tests/_cycle.py +12 -1
- {pytest_digline-0.2.0 → pytest_digline-0.2.2}/tests/test_message.py +54 -0
- pytest_digline-0.2.2/tests/test_summary_note.py +54 -0
- {pytest_digline-0.2.0 → pytest_digline-0.2.2}/.gitignore +0 -0
- {pytest_digline-0.2.0 → pytest_digline-0.2.2}/src/pytest_digline/__init__.py +0 -0
- {pytest_digline-0.2.0 → pytest_digline-0.2.2}/src/pytest_digline/py.typed +0 -0
- {pytest_digline-0.2.0 → pytest_digline-0.2.2}/tests/conftest.py +0 -0
- {pytest_digline-0.2.0 → pytest_digline-0.2.2}/tests/test_calibration_rows.py +0 -0
- {pytest_digline-0.2.0 → pytest_digline-0.2.2}/tests/test_contract.py +0 -0
- {pytest_digline-0.2.0 → pytest_digline-0.2.2}/tests/test_cost.py +0 -0
- {pytest_digline-0.2.0 → pytest_digline-0.2.2}/tests/test_escapes.py +0 -0
- {pytest_digline-0.2.0 → pytest_digline-0.2.2}/tests/test_incomparable_rows.py +0 -0
- {pytest_digline-0.2.0 → pytest_digline-0.2.2}/tests/test_inert.py +0 -0
- {pytest_digline-0.2.0 → pytest_digline-0.2.2}/tests/test_no_promote.py +0 -0
- {pytest_digline-0.2.0 → pytest_digline-0.2.2}/tests/test_states.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: pytest-digline
|
|
3
|
-
Version: 0.2.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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:
|
|
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,
|
|
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()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|