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.
Files changed (20) hide show
  1. {pytest_digline-0.2.2 → pytest_digline-0.2.3}/PKG-INFO +8 -4
  2. {pytest_digline-0.2.2 → pytest_digline-0.2.3}/README.md +6 -2
  3. {pytest_digline-0.2.2 → pytest_digline-0.2.3}/pyproject.toml +11 -2
  4. {pytest_digline-0.2.2 → pytest_digline-0.2.3}/src/pytest_digline/plugin.py +36 -4
  5. pytest_digline-0.2.3/tests/test_exits.py +177 -0
  6. {pytest_digline-0.2.2 → pytest_digline-0.2.3}/.gitignore +0 -0
  7. {pytest_digline-0.2.2 → pytest_digline-0.2.3}/src/pytest_digline/__init__.py +0 -0
  8. {pytest_digline-0.2.2 → pytest_digline-0.2.3}/src/pytest_digline/py.typed +0 -0
  9. {pytest_digline-0.2.2 → pytest_digline-0.2.3}/tests/_cycle.py +0 -0
  10. {pytest_digline-0.2.2 → pytest_digline-0.2.3}/tests/conftest.py +0 -0
  11. {pytest_digline-0.2.2 → pytest_digline-0.2.3}/tests/test_calibration_rows.py +0 -0
  12. {pytest_digline-0.2.2 → pytest_digline-0.2.3}/tests/test_contract.py +0 -0
  13. {pytest_digline-0.2.2 → pytest_digline-0.2.3}/tests/test_cost.py +0 -0
  14. {pytest_digline-0.2.2 → pytest_digline-0.2.3}/tests/test_escapes.py +0 -0
  15. {pytest_digline-0.2.2 → pytest_digline-0.2.3}/tests/test_incomparable_rows.py +0 -0
  16. {pytest_digline-0.2.2 → pytest_digline-0.2.3}/tests/test_inert.py +0 -0
  17. {pytest_digline-0.2.2 → pytest_digline-0.2.3}/tests/test_message.py +0 -0
  18. {pytest_digline-0.2.2 → pytest_digline-0.2.3}/tests/test_no_promote.py +0 -0
  19. {pytest_digline-0.2.2 → pytest_digline-0.2.3}/tests/test_states.py +0 -0
  20. {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.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.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
- One exit code it does carry: a **refusal**. When digline declines the request
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
- One exit code it does carry: a **refusal**. When digline declines the request
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.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
- "digline>=0.20.0",
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(f"digline: {type(exc).__name__}: {exc}") from exc
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