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.
- {pytest_digline-0.2.2 → pytest_digline-0.2.4}/.gitignore +9 -0
- {pytest_digline-0.2.2 → pytest_digline-0.2.4}/PKG-INFO +9 -6
- {pytest_digline-0.2.2 → pytest_digline-0.2.4}/README.md +7 -4
- {pytest_digline-0.2.2 → pytest_digline-0.2.4}/pyproject.toml +18 -2
- {pytest_digline-0.2.2 → pytest_digline-0.2.4}/src/pytest_digline/plugin.py +43 -5
- pytest_digline-0.2.4/tests/test_digline_run_notes.py +103 -0
- pytest_digline-0.2.4/tests/test_exits.py +177 -0
- pytest_digline-0.2.4/tests/test_plugin_one_declared_path.py +93 -0
- {pytest_digline-0.2.2 → pytest_digline-0.2.4}/src/pytest_digline/__init__.py +0 -0
- {pytest_digline-0.2.2 → pytest_digline-0.2.4}/src/pytest_digline/py.typed +0 -0
- {pytest_digline-0.2.2 → pytest_digline-0.2.4}/tests/_cycle.py +0 -0
- {pytest_digline-0.2.2 → pytest_digline-0.2.4}/tests/conftest.py +0 -0
- {pytest_digline-0.2.2 → pytest_digline-0.2.4}/tests/test_calibration_rows.py +0 -0
- {pytest_digline-0.2.2 → pytest_digline-0.2.4}/tests/test_contract.py +0 -0
- {pytest_digline-0.2.2 → pytest_digline-0.2.4}/tests/test_cost.py +0 -0
- {pytest_digline-0.2.2 → pytest_digline-0.2.4}/tests/test_escapes.py +0 -0
- {pytest_digline-0.2.2 → pytest_digline-0.2.4}/tests/test_incomparable_rows.py +0 -0
- {pytest_digline-0.2.2 → pytest_digline-0.2.4}/tests/test_inert.py +0 -0
- {pytest_digline-0.2.2 → pytest_digline-0.2.4}/tests/test_message.py +0 -0
- {pytest_digline-0.2.2 → pytest_digline-0.2.4}/tests/test_no_promote.py +0 -0
- {pytest_digline-0.2.2 → pytest_digline-0.2.4}/tests/test_states.py +0 -0
- {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.
|
|
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
|
+
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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(
|
|
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)
|
|
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
|