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.
- {pytest_digline-0.2.4 → pytest_digline-0.3.0}/PKG-INFO +18 -7
- {pytest_digline-0.2.4 → pytest_digline-0.3.0}/README.md +15 -5
- {pytest_digline-0.2.4 → pytest_digline-0.3.0}/pyproject.toml +20 -2
- {pytest_digline-0.2.4 → pytest_digline-0.3.0}/src/pytest_digline/plugin.py +242 -21
- {pytest_digline-0.2.4 → pytest_digline-0.3.0}/tests/_cycle.py +13 -2
- {pytest_digline-0.2.4 → pytest_digline-0.3.0}/tests/test_contract.py +6 -4
- {pytest_digline-0.2.4 → pytest_digline-0.3.0}/tests/test_message.py +1 -1
- pytest_digline-0.3.0/tests/test_same_suite_twice.py +146 -0
- pytest_digline-0.3.0/tests/test_session_not_green.py +215 -0
- pytest_digline-0.3.0/tests/test_spec_rule_mirrors_the_loader.py +78 -0
- pytest_digline-0.3.0/tests/test_suite_attribute_nodeid.py +225 -0
- pytest_digline-0.3.0/tests/test_summary_names.py +157 -0
- {pytest_digline-0.2.4 → pytest_digline-0.3.0}/.gitignore +0 -0
- {pytest_digline-0.2.4 → pytest_digline-0.3.0}/src/pytest_digline/__init__.py +0 -0
- {pytest_digline-0.2.4 → pytest_digline-0.3.0}/src/pytest_digline/py.typed +0 -0
- {pytest_digline-0.2.4 → pytest_digline-0.3.0}/tests/conftest.py +0 -0
- {pytest_digline-0.2.4 → pytest_digline-0.3.0}/tests/test_calibration_rows.py +0 -0
- {pytest_digline-0.2.4 → pytest_digline-0.3.0}/tests/test_cost.py +0 -0
- {pytest_digline-0.2.4 → pytest_digline-0.3.0}/tests/test_digline_run_notes.py +0 -0
- {pytest_digline-0.2.4 → pytest_digline-0.3.0}/tests/test_escapes.py +0 -0
- {pytest_digline-0.2.4 → pytest_digline-0.3.0}/tests/test_exits.py +0 -0
- {pytest_digline-0.2.4 → pytest_digline-0.3.0}/tests/test_incomparable_rows.py +0 -0
- {pytest_digline-0.2.4 → pytest_digline-0.3.0}/tests/test_inert.py +0 -0
- {pytest_digline-0.2.4 → pytest_digline-0.3.0}/tests/test_no_promote.py +0 -0
- {pytest_digline-0.2.4 → pytest_digline-0.3.0}/tests/test_plugin_one_declared_path.py +0 -0
- {pytest_digline-0.2.4 → pytest_digline-0.3.0}/tests/test_states.py +0 -0
- {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.
|
|
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
|
|
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
|
|
48
|
-
|
|
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
|
-
**
|
|
81
|
-
|
|
82
|
-
|
|
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
|
|
25
|
-
|
|
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
|
-
**
|
|
58
|
-
|
|
59
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
81
|
-
|
|
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[
|
|
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
|
-
|
|
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 =
|
|
214
|
+
node = _suite_node(session, spec, opened, renamed)
|
|
207
215
|
items.extend(node.collect())
|
|
208
|
-
config.stash[
|
|
209
|
-
|
|
210
|
-
|
|
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
|
-
|
|
717
|
-
|
|
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
|
-
|
|
722
|
-
if not
|
|
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
|
|
728
|
-
#
|
|
729
|
-
#
|
|
730
|
-
#
|
|
731
|
-
|
|
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
|
|
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=
|
|
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.
|
|
5
|
-
distinction
|
|
6
|
-
|
|
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
|
|
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
|
|