pytest-digline 0.2.2__tar.gz → 0.2.3__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.2 → pytest_digline-0.2.3}/PKG-INFO +8 -4
- {pytest_digline-0.2.2 → pytest_digline-0.2.3}/README.md +6 -2
- {pytest_digline-0.2.2 → pytest_digline-0.2.3}/pyproject.toml +11 -2
- {pytest_digline-0.2.2 → pytest_digline-0.2.3}/src/pytest_digline/plugin.py +36 -4
- pytest_digline-0.2.3/tests/test_exits.py +177 -0
- {pytest_digline-0.2.2 → pytest_digline-0.2.3}/.gitignore +0 -0
- {pytest_digline-0.2.2 → pytest_digline-0.2.3}/src/pytest_digline/__init__.py +0 -0
- {pytest_digline-0.2.2 → pytest_digline-0.2.3}/src/pytest_digline/py.typed +0 -0
- {pytest_digline-0.2.2 → pytest_digline-0.2.3}/tests/_cycle.py +0 -0
- {pytest_digline-0.2.2 → pytest_digline-0.2.3}/tests/conftest.py +0 -0
- {pytest_digline-0.2.2 → pytest_digline-0.2.3}/tests/test_calibration_rows.py +0 -0
- {pytest_digline-0.2.2 → pytest_digline-0.2.3}/tests/test_contract.py +0 -0
- {pytest_digline-0.2.2 → pytest_digline-0.2.3}/tests/test_cost.py +0 -0
- {pytest_digline-0.2.2 → pytest_digline-0.2.3}/tests/test_escapes.py +0 -0
- {pytest_digline-0.2.2 → pytest_digline-0.2.3}/tests/test_incomparable_rows.py +0 -0
- {pytest_digline-0.2.2 → pytest_digline-0.2.3}/tests/test_inert.py +0 -0
- {pytest_digline-0.2.2 → pytest_digline-0.2.3}/tests/test_message.py +0 -0
- {pytest_digline-0.2.2 → pytest_digline-0.2.3}/tests/test_no_promote.py +0 -0
- {pytest_digline-0.2.2 → pytest_digline-0.2.3}/tests/test_states.py +0 -0
- {pytest_digline-0.2.2 → pytest_digline-0.2.3}/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.
|
|
3
|
+
Version: 0.2.3
|
|
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.30.0
|
|
21
21
|
Requires-Dist: pytest>=8.0
|
|
22
22
|
Description-Content-Type: text/markdown
|
|
23
23
|
|
|
@@ -84,10 +84,14 @@ 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
|
-
|
|
87
|
+
What it does carry is a **refusal**. When digline declines the request
|
|
88
88
|
— no baseline, a document it refuses, a comparison across tenants or across
|
|
89
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`.
|
|
90
|
+
message names the refusal, as `digline compare` names it and exits `64`. So
|
|
91
|
+
does a file that cannot be read or written, and a `sys.exit()` from a target
|
|
92
|
+
under `--digline-run`. Anything else nobody anticipated prints its traceback
|
|
93
|
+
and exits `3`, where the command line exits `70`, with digline's sentence in
|
|
94
|
+
place of pytest's `INTERNALERROR`.
|
|
91
95
|
|
|
92
96
|
## Naming a suite
|
|
93
97
|
|
|
@@ -61,10 +61,14 @@ 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
|
-
|
|
64
|
+
What it does carry is a **refusal**. When digline declines the request
|
|
65
65
|
— no baseline, a document it refuses, a comparison across tenants or across
|
|
66
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`.
|
|
67
|
+
message names the refusal, as `digline compare` names it and exits `64`. So
|
|
68
|
+
does a file that cannot be read or written, and a `sys.exit()` from a target
|
|
69
|
+
under `--digline-run`. Anything else nobody anticipated prints its traceback
|
|
70
|
+
and exits `3`, where the command line exits `70`, with digline's sentence in
|
|
71
|
+
place of pytest's `INTERNALERROR`.
|
|
68
72
|
|
|
69
73
|
## Naming a suite
|
|
70
74
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "pytest-digline"
|
|
3
|
-
version = "0.2.
|
|
3
|
+
version = "0.2.3"
|
|
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"
|
|
@@ -42,7 +42,16 @@ dependencies = [
|
|
|
42
42
|
# 0.20.0, a fourth time, for a name the gate sees: the plugin catches
|
|
43
43
|
# `digline.host.REFUSALS`, which arrived there, and turns every refusal into
|
|
44
44
|
# pytest's usage error rather than an INTERNALERROR.
|
|
45
|
-
|
|
45
|
+
#
|
|
46
|
+
# 0.30.0, a fifth time, by signature: the plugin catches what the CLI's
|
|
47
|
+
# `main()` catches (#475), and refuses a `SystemExit` from code digline
|
|
48
|
+
# runs through `refused_exit`, which the gate dates to 0.28.0, passing
|
|
49
|
+
# `front_end=`, which it cannot see and which first ships in 0.30.0 (#452,
|
|
50
|
+
# ADR 0043 §2, amended). Against 0.29.0 that call raises `TypeError`. It
|
|
51
|
+
# was owed at the cut and raised in this package's own release, after the
|
|
52
|
+
# core was on PyPI, because a floor may not name a release that does not
|
|
53
|
+
# exist yet.
|
|
54
|
+
"digline>=0.30.0",
|
|
46
55
|
# A floor and no cap, unlike `digline-mcp`'s `mcp<3`. That cap records a
|
|
47
56
|
# rename that actually happened; pytest has not moved the `pytest11` entry
|
|
48
57
|
# point in a decade, and a cap here would fight the one dependency the user
|
|
@@ -29,6 +29,7 @@ these sources to keep it that way.
|
|
|
29
29
|
from __future__ import annotations
|
|
30
30
|
|
|
31
31
|
import sys
|
|
32
|
+
import traceback
|
|
32
33
|
from collections.abc import Generator, Iterator, Sequence
|
|
33
34
|
from dataclasses import dataclass
|
|
34
35
|
from pathlib import Path
|
|
@@ -269,16 +270,47 @@ def _open(spec: str, config: pytest.Config) -> _Opened:
|
|
|
269
270
|
one reached pytest as `INTERNALERROR` and exit 3, which reads as a crash of
|
|
270
271
|
pytest or of this plugin. The class is named in the message, as the CLI
|
|
271
272
|
names it. (Delta-pass over 0.25.1)
|
|
273
|
+
|
|
274
|
+
**The four branches are `digline.cli.main()`'s**, mapped onto pytest's
|
|
275
|
+
codes: what the CLI refuses with `64` is pytest's `4` here, and what it
|
|
276
|
+
reports as not anticipated with `70` is pytest's `3` with digline's own
|
|
277
|
+
sentence instead of `INTERNALERROR`, which reads as a crash of pytest or of
|
|
278
|
+
this plugin. An `OSError` is the environment, as on the CLI. A `SystemExit`
|
|
279
|
+
from code digline runs is refused with its location. Each sentence is the
|
|
280
|
+
whole one, because the reader ran the command (ADR 0043 §3), and each passes
|
|
281
|
+
through `visible()`, because a refusal can quote a document or what the
|
|
282
|
+
suite's code wrote. Before #475 only the first two branches were here.
|
|
272
283
|
"""
|
|
273
|
-
from digline.host import REFUSALS, UsageError
|
|
284
|
+
from digline.host import REFUSALS, UsageError, refused_exit
|
|
285
|
+
from digline.report import visible
|
|
274
286
|
|
|
275
287
|
root = Path(config.getoption("digline_root") or config.rootpath)
|
|
276
288
|
try:
|
|
277
289
|
return _opened(spec, root, run_first=bool(config.getoption("digline_run")))
|
|
278
290
|
except UsageError as exc:
|
|
279
|
-
raise pytest.UsageError(f"digline: {exc}") from exc
|
|
280
|
-
except REFUSALS as exc:
|
|
281
|
-
raise pytest.UsageError(
|
|
291
|
+
raise pytest.UsageError(visible(f"digline: {exc}")) from exc
|
|
292
|
+
except (*REFUSALS, OSError) as exc:
|
|
293
|
+
raise pytest.UsageError(
|
|
294
|
+
visible(f"digline: {type(exc).__name__}: {exc}")
|
|
295
|
+
) from exc
|
|
296
|
+
except SystemExit as exc:
|
|
297
|
+
# This plugin's directory is handed over so that its own frames are not
|
|
298
|
+
# given as where the code was reached from. (ADR 0043 §2, amended)
|
|
299
|
+
refused = refused_exit(exc, front_end=Path(__file__).parent)
|
|
300
|
+
if refused is None:
|
|
301
|
+
raise
|
|
302
|
+
raise pytest.UsageError(visible(f"digline: {refused}")) from exc
|
|
303
|
+
except Exception:
|
|
304
|
+
# The traceback is the report, as on the CLI, a line at a time through
|
|
305
|
+
# `visible()`. `pytest.exit` rather than a raise: a raise is pytest's
|
|
306
|
+
# INTERNALERROR, the crash this replaces. (ADR 0041 §2, §5)
|
|
307
|
+
for line in traceback.format_exc().splitlines():
|
|
308
|
+
print(visible(line), file=sys.stderr)
|
|
309
|
+
pytest.exit(
|
|
310
|
+
"digline: the failure above was not anticipated. It is not a verdict "
|
|
311
|
+
"on the suite, and digline's command line exits 70 for it.",
|
|
312
|
+
returncode=pytest.ExitCode.INTERNAL_ERROR,
|
|
313
|
+
)
|
|
282
314
|
|
|
283
315
|
|
|
284
316
|
def _opened(spec: str, root: Path, *, run_first: bool) -> _Opened:
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""What the CLI's `main()` catches, caught here too. (#475)
|
|
2
|
+
|
|
3
|
+
`digline.cli.main()` has four branches: a usage error, a refusal or an
|
|
4
|
+
`OSError`, a `SystemExit` from code digline ran, and anything else. Before #475
|
|
5
|
+
this plugin had the first two without `OSError`, so after a suite had loaded the
|
|
6
|
+
other three reached pytest as `INTERNALERROR` and exit 3, which reads as a crash
|
|
7
|
+
of pytest or of this plugin. Each test names the row of the measurement on the
|
|
8
|
+
issue it holds.
|
|
9
|
+
|
|
10
|
+
Every sentence is the whole one, because the reader ran the command (ADR 0043
|
|
11
|
+
§3), and passes through `visible()` (ADR 0041 §4.1, rule 3).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import os
|
|
17
|
+
import sys
|
|
18
|
+
from collections.abc import Callable
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
import pytest
|
|
22
|
+
|
|
23
|
+
Baseline = Callable[[dict[str, str], dict[str, str]], Path]
|
|
24
|
+
|
|
25
|
+
FINE = {"alpha": "Acme, SATISFIED", "beta": "Acme, SATISFIED"}
|
|
26
|
+
|
|
27
|
+
MARK = "ROWMARK-IT60X0542811101"
|
|
28
|
+
|
|
29
|
+
without_mode_000 = pytest.mark.skipif(
|
|
30
|
+
sys.platform == "win32" or os.geteuid() == 0,
|
|
31
|
+
reason="mode 000 denies nothing to root, and Windows has no such mode",
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _append(path: Path, code: str) -> None:
|
|
36
|
+
path.write_text(path.read_text(encoding="utf-8") + "\n" + code, encoding="utf-8")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _preflight(path: Path, body: str) -> None:
|
|
40
|
+
"""Replace the suite's target with one whose `preflight` runs `body`."""
|
|
41
|
+
_append(
|
|
42
|
+
path,
|
|
43
|
+
"class _Target:\n"
|
|
44
|
+
" def __call__(self, case):\n"
|
|
45
|
+
" return Response(output='Acme, SATISFIED', cost_usd=0.001, "
|
|
46
|
+
"latency_ms=1.0)\n"
|
|
47
|
+
" def preflight(self, cases):\n"
|
|
48
|
+
f" {body}\n"
|
|
49
|
+
"target = _Target()\n",
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _no_crash(result: pytest.RunResult) -> str:
|
|
54
|
+
out = result.stdout.str() + result.stderr.str()
|
|
55
|
+
assert "INTERNALERROR" not in out
|
|
56
|
+
return out
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@without_mode_000
|
|
60
|
+
def test_o1_a_baseline_digline_cannot_read_is_a_usage_error(
|
|
61
|
+
pytester: pytest.Pytester, baseline: Baseline
|
|
62
|
+
) -> None:
|
|
63
|
+
"""O1: an `OSError` digline asked for, as the CLI refuses it with 64."""
|
|
64
|
+
path = baseline(FINE, FINE)
|
|
65
|
+
(stored,) = pytester.path.glob(".digline/*/baselines/*.json")
|
|
66
|
+
stored.chmod(0)
|
|
67
|
+
try:
|
|
68
|
+
result = pytester.runpytest_subprocess("--digline-suite", str(path))
|
|
69
|
+
finally:
|
|
70
|
+
stored.chmod(0o644)
|
|
71
|
+
|
|
72
|
+
assert result.ret == pytest.ExitCode.USAGE_ERROR
|
|
73
|
+
_no_crash(result)
|
|
74
|
+
result.stderr.fnmatch_lines(["*digline: PermissionError: *Errno 13*"])
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@without_mode_000
|
|
78
|
+
def test_o2_a_run_digline_cannot_write_is_a_usage_error(
|
|
79
|
+
pytester: pytest.Pytester, baseline: Baseline
|
|
80
|
+
) -> None:
|
|
81
|
+
"""O2: the same, from `write_run` under `--digline-run`."""
|
|
82
|
+
path = baseline(FINE, FINE)
|
|
83
|
+
(runs,) = pytester.path.glob(".digline/*/runs/*")
|
|
84
|
+
runs.chmod(0o555)
|
|
85
|
+
try:
|
|
86
|
+
result = pytester.runpytest_subprocess(
|
|
87
|
+
"--digline-suite", str(path), "--digline-run"
|
|
88
|
+
)
|
|
89
|
+
finally:
|
|
90
|
+
runs.chmod(0o755)
|
|
91
|
+
|
|
92
|
+
assert result.ret == pytest.ExitCode.USAGE_ERROR
|
|
93
|
+
_no_crash(result)
|
|
94
|
+
result.stderr.fnmatch_lines(["*digline: PermissionError: *Errno 13*"])
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def test_o4_an_oserror_the_suites_code_raises_is_a_usage_error_with_its_message(
|
|
98
|
+
pytester: pytest.Pytester, baseline: Baseline
|
|
99
|
+
) -> None:
|
|
100
|
+
"""O4: a `preflight`'s `OSError`. The CLI refuses it by its type, and the
|
|
101
|
+
whole sentence reaches the person who ran the command, message included."""
|
|
102
|
+
path = baseline(FINE, FINE)
|
|
103
|
+
_preflight(path, f"open('/nonexistent/{MARK}')")
|
|
104
|
+
|
|
105
|
+
result = pytester.runpytest_subprocess(
|
|
106
|
+
"--digline-suite", str(path), "--digline-run"
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
assert result.ret == pytest.ExitCode.USAGE_ERROR
|
|
110
|
+
_no_crash(result)
|
|
111
|
+
result.stderr.fnmatch_lines([f"*digline: FileNotFoundError: *{MARK}*"])
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def test_s2_a_target_that_calls_sys_exit_is_refused_with_its_location(
|
|
115
|
+
pytester: pytest.Pytester, baseline: Baseline
|
|
116
|
+
) -> None:
|
|
117
|
+
"""S2: ADR 0041 §4.3. The location is the suite's line, and none of this
|
|
118
|
+
plugin's frames is given as where the code was reached from."""
|
|
119
|
+
path = baseline(FINE, FINE)
|
|
120
|
+
_append(path, f"def target(case):\n import sys\n sys.exit({MARK!r})\n")
|
|
121
|
+
|
|
122
|
+
result = pytester.runpytest_subprocess(
|
|
123
|
+
"--digline-suite", str(path), "--digline-run"
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
assert result.ret == pytest.ExitCode.USAGE_ERROR
|
|
127
|
+
out = _no_crash(result)
|
|
128
|
+
result.stderr.fnmatch_lines(
|
|
129
|
+
[f"*digline: code digline ran raised SystemExit('{MARK}') at *suite.py:*"]
|
|
130
|
+
)
|
|
131
|
+
assert "pytest_digline" not in out
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def test_x2_anything_else_is_not_anticipated_and_not_a_crash(
|
|
135
|
+
pytester: pytest.Pytester, baseline: Baseline
|
|
136
|
+
) -> None:
|
|
137
|
+
"""X2: the CLI exits 70 with the traceback. Here it is pytest's 3, with the
|
|
138
|
+
traceback and digline's sentence in place of `INTERNALERROR`."""
|
|
139
|
+
path = baseline(FINE, FINE)
|
|
140
|
+
_preflight(path, f"raise ValueError({MARK!r})")
|
|
141
|
+
|
|
142
|
+
result = pytester.runpytest_subprocess(
|
|
143
|
+
"--digline-suite", str(path), "--digline-run"
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
assert result.ret == pytest.ExitCode.INTERNAL_ERROR
|
|
147
|
+
out = _no_crash(result)
|
|
148
|
+
result.stderr.fnmatch_lines(
|
|
149
|
+
["Traceback (most recent call last):", f"*ValueError: {MARK}"]
|
|
150
|
+
)
|
|
151
|
+
assert "the failure above was not anticipated" in out
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@pytest.mark.parametrize(
|
|
155
|
+
"refusal",
|
|
156
|
+
[
|
|
157
|
+
pytest.param("digline.core import RefusedError as R", id="refusal"),
|
|
158
|
+
pytest.param("digline.host import UsageError as R", id="usage-error"),
|
|
159
|
+
],
|
|
160
|
+
)
|
|
161
|
+
def test_esc_a_refusals_sentence_reaches_the_terminal_through_visible(
|
|
162
|
+
pytester: pytest.Pytester, baseline: Baseline, refusal: str
|
|
163
|
+
) -> None:
|
|
164
|
+
"""A refusal can quote what the suite's code wrote. An escape in it is
|
|
165
|
+
shown, not obeyed, as `digline run` shows it. Both branches: `UsageError`
|
|
166
|
+
has one of its own."""
|
|
167
|
+
path = baseline(FINE, FINE)
|
|
168
|
+
_preflight(path, f'from {refusal}; raise R("A\\x1b[2KFORGED")')
|
|
169
|
+
|
|
170
|
+
result = pytester.runpytest_subprocess(
|
|
171
|
+
"--digline-suite", str(path), "--digline-run"
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
assert result.ret == pytest.ExitCode.USAGE_ERROR
|
|
175
|
+
err = result.stderr.str()
|
|
176
|
+
assert "\x1b" not in err
|
|
177
|
+
assert "A\\x1b[2KFORGED" in err
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|