pytest-digline 0.2.2__tar.gz → 0.2.4__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 (22) hide show
  1. {pytest_digline-0.2.2 → pytest_digline-0.2.4}/.gitignore +9 -0
  2. {pytest_digline-0.2.2 → pytest_digline-0.2.4}/PKG-INFO +9 -6
  3. {pytest_digline-0.2.2 → pytest_digline-0.2.4}/README.md +7 -4
  4. {pytest_digline-0.2.2 → pytest_digline-0.2.4}/pyproject.toml +18 -2
  5. {pytest_digline-0.2.2 → pytest_digline-0.2.4}/src/pytest_digline/plugin.py +43 -5
  6. pytest_digline-0.2.4/tests/test_digline_run_notes.py +103 -0
  7. pytest_digline-0.2.4/tests/test_exits.py +177 -0
  8. pytest_digline-0.2.4/tests/test_plugin_one_declared_path.py +93 -0
  9. {pytest_digline-0.2.2 → pytest_digline-0.2.4}/src/pytest_digline/__init__.py +0 -0
  10. {pytest_digline-0.2.2 → pytest_digline-0.2.4}/src/pytest_digline/py.typed +0 -0
  11. {pytest_digline-0.2.2 → pytest_digline-0.2.4}/tests/_cycle.py +0 -0
  12. {pytest_digline-0.2.2 → pytest_digline-0.2.4}/tests/conftest.py +0 -0
  13. {pytest_digline-0.2.2 → pytest_digline-0.2.4}/tests/test_calibration_rows.py +0 -0
  14. {pytest_digline-0.2.2 → pytest_digline-0.2.4}/tests/test_contract.py +0 -0
  15. {pytest_digline-0.2.2 → pytest_digline-0.2.4}/tests/test_cost.py +0 -0
  16. {pytest_digline-0.2.2 → pytest_digline-0.2.4}/tests/test_escapes.py +0 -0
  17. {pytest_digline-0.2.2 → pytest_digline-0.2.4}/tests/test_incomparable_rows.py +0 -0
  18. {pytest_digline-0.2.2 → pytest_digline-0.2.4}/tests/test_inert.py +0 -0
  19. {pytest_digline-0.2.2 → pytest_digline-0.2.4}/tests/test_message.py +0 -0
  20. {pytest_digline-0.2.2 → pytest_digline-0.2.4}/tests/test_no_promote.py +0 -0
  21. {pytest_digline-0.2.2 → pytest_digline-0.2.4}/tests/test_states.py +0 -0
  22. {pytest_digline-0.2.2 → pytest_digline-0.2.4}/tests/test_summary_note.py +0 -0
@@ -22,6 +22,9 @@ dist/
22
22
  .idea/
23
23
  *.iml
24
24
 
25
+ # VS Code. Not used today; ignored as prevention.
26
+ .vscode/
27
+
25
28
  # Local Claude Code settings. `.claude/settings.json`, if it appears, is shared
26
29
  # and stays versioned. `CLAUDE.local.md` is the personal working agreement —
27
30
  # how I want to be worked with — as against `CLAUDE.md`, which is the project.
@@ -38,6 +41,12 @@ private/
38
41
  # artifacts are not — and the split is enforced one level down, by the
39
42
  # `.gitignore` the store itself writes into `.digline/` (`*/runs/`).
40
43
  # Ignoring `.digline/` here would take the baselines out of git with it.
44
+
45
+ # Output of the release step, like dist/. .github/select_unpublished.py writes
46
+ # into to-publish/ the distributions PyPI and TestPyPI do not already have, and
47
+ # publish.yml uses it as packages-dir. It lands in the working copy of whoever
48
+ # runs that step by hand. The line arrived in b5870a6 without a reason beside
49
+ # it; the reason was read from the code and written here on 2026-10-08.
41
50
  to-publish/
42
51
 
43
52
  # The site checkout that ci.yml's `docs` job makes, and that RELEASING
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: pytest-digline
3
- Version: 0.2.2
3
+ Version: 0.2.4
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.31.0
21
21
  Requires-Dist: pytest>=8.0
22
22
  Description-Content-Type: text/markdown
23
23
 
@@ -40,8 +40,7 @@ eval/suite.py::gamma s
40
40
 
41
41
  =================================== FAILURES ===================================
42
42
  digline: alpha · llm_rubric
43
- dropped from 0.910000 to 0.640000, below its threshold of 0.700000,
44
- and beyond the 0.880000–0.950000 this check measured across 5 samples
43
+ Went from passing to failing (0.910000 → 0.640000).
45
44
  reason: signed=True, concise=False
46
45
 
47
46
  ==================================== digline ===================================
@@ -84,10 +83,14 @@ for either. The distinction survives in the report — `F` and `E` are counted
84
83
  separately, `-rE` lists the errored rows — and in `--junit-xml`. A job that
85
84
  needs `1` versus `2` runs `digline compare`, which is one command away.
86
85
 
87
- One exit code it does carry: a **refusal**. When digline declines the request
86
+ What it does carry is a **refusal**. When digline declines the request
88
87
  — no baseline, a document it refuses, a comparison across tenants or across
89
88
  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`.
89
+ message names the refusal, as `digline compare` names it and exits `64`. So
90
+ does a file that cannot be read or written, and a `sys.exit()` from a target
91
+ under `--digline-run`. Anything else nobody anticipated prints its traceback
92
+ and exits `3`, where the command line exits `70`, with digline's sentence in
93
+ place of pytest's `INTERNALERROR`.
91
94
 
92
95
  ## Naming a suite
93
96
 
@@ -17,8 +17,7 @@ eval/suite.py::gamma s
17
17
 
18
18
  =================================== FAILURES ===================================
19
19
  digline: alpha · llm_rubric
20
- dropped from 0.910000 to 0.640000, below its threshold of 0.700000,
21
- and beyond the 0.880000–0.950000 this check measured across 5 samples
20
+ Went from passing to failing (0.910000 → 0.640000).
22
21
  reason: signed=True, concise=False
23
22
 
24
23
  ==================================== digline ===================================
@@ -61,10 +60,14 @@ for either. The distinction survives in the report — `F` and `E` are counted
61
60
  separately, `-rE` lists the errored rows — and in `--junit-xml`. A job that
62
61
  needs `1` versus `2` runs `digline compare`, which is one command away.
63
62
 
64
- One exit code it does carry: a **refusal**. When digline declines the request
63
+ What it does carry is a **refusal**. When digline declines the request
65
64
  — no baseline, a document it refuses, a comparison across tenants or across
66
65
  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`.
66
+ message names the refusal, as `digline compare` names it and exits `64`. So
67
+ does a file that cannot be read or written, and a `sys.exit()` from a target
68
+ under `--digline-run`. Anything else nobody anticipated prints its traceback
69
+ and exits `3`, where the command line exits `70`, with digline's sentence in
70
+ place of pytest's `INTERNALERROR`.
68
71
 
69
72
  ## Naming a suite
70
73
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "pytest-digline"
3
- version = "0.2.2"
3
+ version = "0.2.4"
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,23 @@ 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
+ #
55
+ # 0.31.0, a sixth time, by name: `--digline-run` imports `suite_notes`,
56
+ # which first ships in 0.31.0, so the lines `digline run` prints about the
57
+ # suite before the first call reach a pytest session too (#396, ADR 0024
58
+ # §6.4). Against 0.30.0 the import raises `ImportError`. It was owed at the
59
+ # cut and raised in this package's own release, after the core was on
60
+ # PyPI, because a floor may not name a release that does not exist yet.
61
+ "digline>=0.31.0",
46
62
  # A floor and no cap, unlike `digline-mcp`'s `mcp<3`. That cap records a
47
63
  # rename that actually happened; pytest has not moved the `pytest11` entry
48
64
  # 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:
@@ -338,7 +370,7 @@ def _measure(
338
370
  utc_now_iso,
339
371
  )
340
372
  from digline.report import visible
341
- from digline.run import execute, planned_calls
373
+ from digline.run import execute, planned_calls, suite_notes
342
374
 
343
375
  target = load_target(None, loaded, spec)
344
376
  plan = planned_calls(suite)
@@ -348,6 +380,12 @@ def _measure(
348
380
  # write left in any front end when the rule was widened past `digline.cli`.
349
381
  # (from the release delta-pass over 0.15.0)
350
382
  print(f"digline: {visible(plan.sentence())}", file=sys.stderr)
383
+ # Beside the count, as `digline run` says them: whoever writes a suite in
384
+ # pytest may never run the CLI, and is the one these lines are for. Until
385
+ # #396 the plugin said neither, under a rule against raising its floor for
386
+ # a line on stderr; that rule was withdrawn on 2026-10-07. (ADR 0024 §6.4)
387
+ for note in suite_notes(suite):
388
+ print(f"digline: {visible(note)}", file=sys.stderr)
351
389
  commit = git_commit(root)
352
390
  created_at = utc_now_iso()
353
391
  artifacts = read_artifacts(suite, target, path.parent, root=root)
@@ -0,0 +1,103 @@
1
+ """`--digline-run` says what `digline run` says about the suite. (#396)
2
+
3
+ Before the first call the CLI names a check whose class declares no `KIND` and
4
+ a tolerance that switches its check off. Whoever writes a suite in pytest may
5
+ never run the CLI, and is the one both lines are for. Until #396 the plugin
6
+ said neither, under a rule against raising its floor for a line on stderr; the
7
+ rule was withdrawn on 2026-10-07. (ADR 0024 §6.4)
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import pytest
13
+
14
+ NOTED = """
15
+ from dataclasses import dataclass
16
+
17
+ from digline.core import (
18
+ TEXT_ONLY,
19
+ AssertionBase,
20
+ Contains,
21
+ EvaluatorInputs,
22
+ JudgeReply,
23
+ LlmRubric,
24
+ OutputKind,
25
+ Verdict,
26
+ )
27
+ from digline.run import Case, Response, Suite
28
+
29
+
30
+ @dataclass(frozen=True, slots=True)
31
+ class NoKind(AssertionBase):
32
+ name: str = "no_kind"
33
+ threshold: float = 0.5
34
+ tolerance: float = 0.0
35
+ accepts: frozenset[OutputKind] = TEXT_ONLY
36
+
37
+ def __call__(self, inputs: EvaluatorInputs) -> Verdict:
38
+ return self._binary(True, "fine")
39
+
40
+
41
+ def judge(prompt):
42
+ return JudgeReply(score=1.0, reason="looked")
43
+
44
+
45
+ suite = Suite(
46
+ tenant="acme",
47
+ environment="staging",
48
+ name="noted",
49
+ assertions=[
50
+ Contains(needle="Acme"),
51
+ NoKind(),
52
+ LlmRubric(rubric="ok?", judge=judge, threshold=0.5, tolerance=0.5,
53
+ name="loose"),
54
+ ],
55
+ cases=[Case(id="alpha")],
56
+ )
57
+
58
+
59
+ def target(case):
60
+ return Response(output="Acme", input="who?")
61
+ """
62
+
63
+
64
+ def test_the_notes_follow_the_count_on_stderr(pytester: pytest.Pytester) -> None:
65
+ path = pytester.path / "suite.py"
66
+ path.write_text(NOTED, encoding="utf-8")
67
+
68
+ result = pytester.runpytest_subprocess(
69
+ "--digline-suite", str(path), "--digline-run"
70
+ )
71
+
72
+ # The order `digline run` prints: the count, the KIND note, each blind
73
+ # tolerance. The session itself ends on the missing baseline, which is not
74
+ # what is under test here.
75
+ result.stderr.fnmatch_lines(
76
+ [
77
+ "digline: tenant 'acme' * = 1 call to the target",
78
+ "digline: no_kind declares no KIND, so the shape reading leaves it out*",
79
+ "digline: tolerance 0.5 on 'loose' (threshold 0.5) covers every movement*",
80
+ ],
81
+ consecutive=True,
82
+ )
83
+
84
+
85
+ def test_a_suite_with_nothing_to_say_says_only_the_count(
86
+ pytester: pytest.Pytester,
87
+ ) -> None:
88
+ """The control: a note on every run would be a line people learn to skip,
89
+ and would pass the test above."""
90
+ path = pytester.path / "suite.py"
91
+ plain = NOTED.replace(" NoKind(),\n", "").replace(
92
+ "tolerance=0.5", "tolerance=0.05"
93
+ )
94
+ path.write_text(plain, encoding="utf-8")
95
+
96
+ result = pytester.runpytest_subprocess(
97
+ "--digline-suite", str(path), "--digline-run"
98
+ )
99
+
100
+ text = result.stderr.str()
101
+ assert "= 1 call to the target" in text
102
+ assert "KIND" not in text
103
+ assert "covers every movement" not in text
@@ -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
@@ -0,0 +1,93 @@
1
+ """ADR 0045 through pytest-digline: test plan entry 9, by its flag and its ini.
2
+
3
+ A real pytest run in a subprocess, started from the working directory under
4
+ test: `pytester` would start it from its own directory, and the directory is
5
+ what decides which prompt a bare relative path reads. The layout is #481's,
6
+ from `tests/_one_path.py`.
7
+
8
+ The run is not promoted, so a run that gets past the files under test ends on
9
+ "no baseline" (exit 4) after its run file is written. What these tests read is
10
+ that file and what the provider was sent, not the gate's verdict.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import os
16
+ import subprocess
17
+ import sys
18
+ from pathlib import Path
19
+
20
+ import pytest
21
+ from tests._one_path import RELATIVE, layout, recorded, sent, sha
22
+
23
+
24
+ def _pytest(tmp_path: Path, cwd: str, how: str) -> subprocess.CompletedProcess[str]:
25
+ """`--digline-suite eval/suite.py` from `R`, or the absolute suite from
26
+ elsewhere; or the `digline_suites` ini, which pytest makes absolute."""
27
+ root = tmp_path / "R"
28
+ args = [
29
+ sys.executable,
30
+ "-m",
31
+ "pytest",
32
+ "-p",
33
+ "no:cacheprovider",
34
+ "--digline-run",
35
+ "--digline-root",
36
+ str(root),
37
+ "--rootdir",
38
+ str(root),
39
+ ]
40
+ if how == "ini":
41
+ (root / "pytest.ini").write_text(
42
+ "[pytest]\ndigline_suites =\n eval/suite.py\n", "utf-8"
43
+ )
44
+ args += ["-c", str(root / "pytest.ini")]
45
+ else:
46
+ spec = "eval/suite.py" if cwd == "R" else str(root / "eval" / "suite.py")
47
+ args += ["-c", os.devnull, "--digline-suite", spec]
48
+ return subprocess.run(
49
+ args, cwd=tmp_path / cwd, capture_output=True, text=True, check=False
50
+ )
51
+
52
+
53
+ def _runs(root: Path) -> list[Path]:
54
+ return list((root / ".digline").rglob("runs/*/*.json"))
55
+
56
+
57
+ @pytest.mark.parametrize("how", ["flag", "ini"])
58
+ def test_1_from_the_root_the_run_records_the_file_it_sent(
59
+ tmp_path: Path, how: str
60
+ ) -> None:
61
+ root = layout(tmp_path)
62
+ done = _pytest(tmp_path, "R", how)
63
+ assert "no baseline" in done.stdout + done.stderr, done.stdout + done.stderr
64
+ assert sent(tmp_path) == ["text of R\n"]
65
+ (run,) = _runs(root)
66
+ assert recorded(root, run.stem) == {
67
+ "prompt.txt": {"sha": sha(root / "prompt.txt"), "text": "text of R\n"}
68
+ }
69
+
70
+
71
+ @pytest.mark.parametrize("how", ["flag", "ini"])
72
+ def test_2_from_outside_the_run_is_refused_and_nothing_is_sent(
73
+ tmp_path: Path, how: str
74
+ ) -> None:
75
+ root = layout(tmp_path)
76
+ done = _pytest(tmp_path, "OUT", how)
77
+ out = done.stdout + done.stderr
78
+ assert "the target Stub of suite 'qa'" in out, out
79
+ assert "(ADR 0042 §2)" in out
80
+ assert sent(tmp_path) == []
81
+ assert not _runs(root)
82
+
83
+
84
+ @pytest.mark.parametrize("how", ["flag", "ini"])
85
+ def test_4_a_relative_answer_is_refused_naming_the_target(
86
+ tmp_path: Path, how: str
87
+ ) -> None:
88
+ root = layout(tmp_path, target=RELATIVE)
89
+ done = _pytest(tmp_path, "R", how)
90
+ out = done.stdout + done.stderr
91
+ assert "the target Naming of suite 'qa'" in out, out
92
+ assert "a relative path" in out
93
+ assert not _runs(root)