pytest-grounding 0.0.2__tar.gz → 0.0.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_grounding-0.0.2/pytest_grounding.egg-info → pytest_grounding-0.0.4}/PKG-INFO +1 -1
- {pytest_grounding-0.0.2 → pytest_grounding-0.0.4}/grounding/__init__.py +3 -2
- {pytest_grounding-0.0.2 → pytest_grounding-0.0.4}/pyproject.toml +1 -1
- {pytest_grounding-0.0.2 → pytest_grounding-0.0.4/pytest_grounding.egg-info}/PKG-INFO +1 -1
- {pytest_grounding-0.0.2 → pytest_grounding-0.0.4}/pytest_grounding.egg-info/SOURCES.txt +1 -0
- pytest_grounding-0.0.4/tests/test_merge.py +249 -0
- {pytest_grounding-0.0.2 → pytest_grounding-0.0.4}/tests/test_text.py +10 -0
- {pytest_grounding-0.0.2 → pytest_grounding-0.0.4}/LICENSE +0 -0
- {pytest_grounding-0.0.2 → pytest_grounding-0.0.4}/README.md +0 -0
- {pytest_grounding-0.0.2 → pytest_grounding-0.0.4}/grounding/_capture.py +0 -0
- {pytest_grounding-0.0.2 → pytest_grounding-0.0.4}/grounding/_normalize.py +0 -0
- {pytest_grounding-0.0.2 → pytest_grounding-0.0.4}/grounding/_text.py +0 -0
- {pytest_grounding-0.0.2 → pytest_grounding-0.0.4}/grounding/claim.py +0 -0
- {pytest_grounding-0.0.2 → pytest_grounding-0.0.4}/grounding/cli.py +0 -0
- {pytest_grounding-0.0.2 → pytest_grounding-0.0.4}/grounding/guard.py +0 -0
- {pytest_grounding-0.0.2 → pytest_grounding-0.0.4}/grounding/loaders.py +0 -0
- {pytest_grounding-0.0.2 → pytest_grounding-0.0.4}/grounding/plugin.py +0 -0
- {pytest_grounding-0.0.2 → pytest_grounding-0.0.4}/grounding/report_io.py +0 -0
- {pytest_grounding-0.0.2 → pytest_grounding-0.0.4}/grounding/trace.py +0 -0
- {pytest_grounding-0.0.2 → pytest_grounding-0.0.4}/pytest_grounding.egg-info/dependency_links.txt +0 -0
- {pytest_grounding-0.0.2 → pytest_grounding-0.0.4}/pytest_grounding.egg-info/entry_points.txt +0 -0
- {pytest_grounding-0.0.2 → pytest_grounding-0.0.4}/pytest_grounding.egg-info/requires.txt +0 -0
- {pytest_grounding-0.0.2 → pytest_grounding-0.0.4}/pytest_grounding.egg-info/top_level.txt +0 -0
- {pytest_grounding-0.0.2 → pytest_grounding-0.0.4}/setup.cfg +0 -0
- {pytest_grounding-0.0.2 → pytest_grounding-0.0.4}/tests/test_capture.py +0 -0
- {pytest_grounding-0.0.2 → pytest_grounding-0.0.4}/tests/test_loaders.py +0 -0
- {pytest_grounding-0.0.2 → pytest_grounding-0.0.4}/tests/test_plugin.py +0 -0
- {pytest_grounding-0.0.2 → pytest_grounding-0.0.4}/tests/test_trace.py +0 -0
|
@@ -40,6 +40,7 @@ from ._capture import (
|
|
|
40
40
|
record,
|
|
41
41
|
registry,
|
|
42
42
|
)
|
|
43
|
+
from ._normalize import collapse_ws, fold_match
|
|
43
44
|
from ._text import match_phrase, sha256
|
|
44
45
|
from .loaders import (
|
|
45
46
|
DocRef,
|
|
@@ -57,8 +58,8 @@ __all__ = [
|
|
|
57
58
|
"statement", "evidence", "uses",
|
|
58
59
|
"strength", "caveats", "kind", "reviewed",
|
|
59
60
|
"Capture", "current_capture", "record", "registry", "TRACKED_SUFFIXES",
|
|
60
|
-
"match_phrase", "sha256",
|
|
61
|
+
"match_phrase", "sha256", "collapse_ws", "fold_match",
|
|
61
62
|
"install_guard",
|
|
62
63
|
]
|
|
63
64
|
|
|
64
|
-
__version__ = "0.0.
|
|
65
|
+
__version__ = "0.0.4"
|
|
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
|
|
|
7
7
|
# makes it discoverable); the import package stays `grounding` (the friendly-import pattern
|
|
8
8
|
# used by pytest-mock / pytest-django): pip install pytest-grounding, from grounding import …
|
|
9
9
|
name = "pytest-grounding"
|
|
10
|
-
version = "0.0.
|
|
10
|
+
version = "0.0.4"
|
|
11
11
|
description = "Turn assertions about data into re-runnable, provenance-tracked claims — written and reviewed by agents."
|
|
12
12
|
readme = "README.md"
|
|
13
13
|
requires-python = ">=3.9"
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
"""Tests for the grounding-report writer's **merge-by-test-file** behavior.
|
|
2
|
+
|
|
3
|
+
The writer (``pytest_sessionfinish`` in ``grounding.plugin``) used to fully overwrite
|
|
4
|
+
``grounding_report.json`` with only the claims collected in the current run, so a partial
|
|
5
|
+
run (e.g. ``pytest claims/test_one.py --grounding-out analysis``) silently dropped every
|
|
6
|
+
other module's claims — making unrelated reports that resolve their ``[claim:]`` against
|
|
7
|
+
that file go spuriously BROKEN. The writer now merges this run's records into the existing
|
|
8
|
+
report at **test-file granularity**.
|
|
9
|
+
|
|
10
|
+
These tests use only throwaway ``tmp_path`` dirs and a tiny fake pytest config (plus one
|
|
11
|
+
``pytester`` end-to-end check); they never touch any real data and never invoke a model.
|
|
12
|
+
"""
|
|
13
|
+
import json
|
|
14
|
+
|
|
15
|
+
from grounding import plugin as P
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# --------------------------------------------------------------------------- #
|
|
19
|
+
# helpers / fakes
|
|
20
|
+
# --------------------------------------------------------------------------- #
|
|
21
|
+
def _rec(id_, *, outcome="passed", kind="result", statement=""):
|
|
22
|
+
"""A minimal claim record shaped like the ones the plugin collects."""
|
|
23
|
+
return {
|
|
24
|
+
"id": id_,
|
|
25
|
+
"statement": statement,
|
|
26
|
+
"notes": None,
|
|
27
|
+
"outcome": outcome,
|
|
28
|
+
"kind": kind,
|
|
29
|
+
"strength": "unspecified",
|
|
30
|
+
"caveats": None,
|
|
31
|
+
"reviewed": None,
|
|
32
|
+
"evidence": {},
|
|
33
|
+
"inputs": [],
|
|
34
|
+
"bypassed": [],
|
|
35
|
+
"advisories": [],
|
|
36
|
+
"longrepr": None,
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class _FakeConfig:
|
|
41
|
+
"""Just enough of a pytest config for ``pytest_sessionfinish``: the collected records,
|
|
42
|
+
a ``--grounding-out`` dir, and the ``grounding_fresh`` flag."""
|
|
43
|
+
|
|
44
|
+
def __init__(self, records, out_dir, fresh=False):
|
|
45
|
+
self._grounding_records = records
|
|
46
|
+
self.rootpath = out_dir
|
|
47
|
+
self._out = str(out_dir)
|
|
48
|
+
self._fresh = fresh
|
|
49
|
+
|
|
50
|
+
def getoption(self, name, default=None):
|
|
51
|
+
if name == "--grounding-out":
|
|
52
|
+
return self._out
|
|
53
|
+
if name == "grounding_fresh":
|
|
54
|
+
return self._fresh
|
|
55
|
+
return default
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class _FakeSession:
|
|
59
|
+
def __init__(self, config):
|
|
60
|
+
self.config = config
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _run(records, out_dir, fresh=False):
|
|
64
|
+
"""Drive the writer once and return (json_dict, md_text)."""
|
|
65
|
+
P.pytest_sessionfinish(_FakeSession(_FakeConfig(records, out_dir, fresh=fresh)))
|
|
66
|
+
data = json.loads((out_dir / "grounding_report.json").read_text(encoding="utf-8"))
|
|
67
|
+
md = (out_dir / "grounding_report.md").read_text(encoding="utf-8")
|
|
68
|
+
return data, md
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _ids(data):
|
|
72
|
+
return {c["id"] for c in data["claims"]}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# --------------------------------------------------------------------------- #
|
|
76
|
+
# _test_file_of — the merge grain
|
|
77
|
+
# --------------------------------------------------------------------------- #
|
|
78
|
+
def test_test_file_of_basename_independent_of_prefix():
|
|
79
|
+
# program-style prefix
|
|
80
|
+
assert P._test_file_of(_rec("program/claims/test_lit.py::test_a")) == "test_lit.py"
|
|
81
|
+
# experiment-style prefix (spaces and parens in the dir name)
|
|
82
|
+
assert (P._test_file_of(
|
|
83
|
+
_rec("K1-230102 - UNC In Vivo/analysis/claims/test_K1_230102.py::test_a"))
|
|
84
|
+
== "test_K1_230102.py")
|
|
85
|
+
# parametrized node id
|
|
86
|
+
assert P._test_file_of(_rec("a/b/test_x.py::test_y[1-2]")) == "test_x.py"
|
|
87
|
+
# bare nodeid with no path component still yields something stable
|
|
88
|
+
assert P._test_file_of(_rec("test_x.py::test_y")) == "test_x.py"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
# --------------------------------------------------------------------------- #
|
|
92
|
+
# _merge_records — pure union logic
|
|
93
|
+
# --------------------------------------------------------------------------- #
|
|
94
|
+
def test_merge_preserves_untouched_files_and_replaces_run_files():
|
|
95
|
+
prior = [
|
|
96
|
+
_rec("program/claims/test_a.py::test_1"),
|
|
97
|
+
_rec("program/claims/test_a.py::test_2"),
|
|
98
|
+
_rec("program/claims/test_b.py::test_keep"),
|
|
99
|
+
]
|
|
100
|
+
# this run only touched test_a.py: edited test_1, dropped test_2, added test_3
|
|
101
|
+
current = [
|
|
102
|
+
_rec("program/claims/test_a.py::test_1", outcome="failed"),
|
|
103
|
+
_rec("program/claims/test_a.py::test_3"),
|
|
104
|
+
]
|
|
105
|
+
merged = P._merge_records(prior, current)
|
|
106
|
+
ids = {r["id"] for r in merged}
|
|
107
|
+
# test_b (untouched) preserved
|
|
108
|
+
assert "program/claims/test_b.py::test_keep" in ids
|
|
109
|
+
# test_a fully replaced by this run: test_2 gone, test_3 added
|
|
110
|
+
assert "program/claims/test_a.py::test_2" not in ids
|
|
111
|
+
assert "program/claims/test_a.py::test_3" in ids
|
|
112
|
+
# edit reflected
|
|
113
|
+
edited = next(r for r in merged if r["id"] == "program/claims/test_a.py::test_1")
|
|
114
|
+
assert edited["outcome"] == "failed"
|
|
115
|
+
# deterministic order (sorted by id)
|
|
116
|
+
assert [r["id"] for r in merged] == sorted(r["id"] for r in merged)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def test_merge_full_run_replaces_everything():
|
|
120
|
+
prior = [_rec("c/test_a.py::t1"), _rec("c/test_b.py::t2")]
|
|
121
|
+
current = [_rec("c/test_a.py::t1", outcome="failed"), _rec("c/test_b.py::t2", outcome="failed")]
|
|
122
|
+
merged = P._merge_records(prior, current)
|
|
123
|
+
assert {r["id"] for r in merged} == {"c/test_a.py::t1", "c/test_b.py::t2"}
|
|
124
|
+
assert all(r["outcome"] == "failed" for r in merged) # all came from this run
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
# --------------------------------------------------------------------------- #
|
|
128
|
+
# _load_prior_records — graceful degradation
|
|
129
|
+
# --------------------------------------------------------------------------- #
|
|
130
|
+
def test_load_prior_absent(tmp_path):
|
|
131
|
+
assert P._load_prior_records(tmp_path / "nope.json") == []
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def test_load_prior_corrupt(tmp_path):
|
|
135
|
+
p = tmp_path / "grounding_report.json"
|
|
136
|
+
p.write_text("{ this is not json", encoding="utf-8")
|
|
137
|
+
assert P._load_prior_records(p) == []
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
# --------------------------------------------------------------------------- #
|
|
141
|
+
# end-to-end writer behavior
|
|
142
|
+
# --------------------------------------------------------------------------- #
|
|
143
|
+
def test_partial_run_preserves_other_files(tmp_path):
|
|
144
|
+
# seed a report with two files' claims
|
|
145
|
+
_run([_rec("program/claims/test_a.py::test_a1"),
|
|
146
|
+
_rec("program/claims/test_b.py::test_b1")], tmp_path)
|
|
147
|
+
# now a partial run of only test_a.py
|
|
148
|
+
data, md = _run([_rec("program/claims/test_a.py::test_a1", outcome="failed")], tmp_path)
|
|
149
|
+
assert _ids(data) == {
|
|
150
|
+
"program/claims/test_a.py::test_a1",
|
|
151
|
+
"program/claims/test_b.py::test_b1", # preserved!
|
|
152
|
+
}
|
|
153
|
+
# the .md reflects the UNION, not just this run
|
|
154
|
+
assert "test_a1" in md and "test_b1" in md
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def test_rerun_one_file_reflects_add_edit_delete(tmp_path):
|
|
158
|
+
_run([_rec("c/test_a.py::keep"),
|
|
159
|
+
_rec("c/test_a.py::drop_me"),
|
|
160
|
+
_rec("c/test_b.py::other")], tmp_path)
|
|
161
|
+
data, _ = _run([_rec("c/test_a.py::keep", outcome="failed"),
|
|
162
|
+
_rec("c/test_a.py::added")], tmp_path)
|
|
163
|
+
assert _ids(data) == {"c/test_a.py::keep", "c/test_a.py::added", "c/test_b.py::other"}
|
|
164
|
+
edited = next(c for c in data["claims"] if c["id"] == "c/test_a.py::keep")
|
|
165
|
+
assert edited["outcome"] == "failed"
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def test_full_run_replaces_everything_e2e(tmp_path):
|
|
169
|
+
_run([_rec("c/test_a.py::a"), _rec("c/test_b.py::b")], tmp_path)
|
|
170
|
+
# full run collecting BOTH files again, both now failing, plus drops test_a::a
|
|
171
|
+
data, _ = _run([_rec("c/test_a.py::a2"), _rec("c/test_b.py::b")], tmp_path)
|
|
172
|
+
assert _ids(data) == {"c/test_a.py::a2", "c/test_b.py::b"}
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def test_grounding_fresh_overwrites(tmp_path):
|
|
176
|
+
_run([_rec("c/test_a.py::a"), _rec("c/test_b.py::b")], tmp_path)
|
|
177
|
+
data, md = _run([_rec("c/test_a.py::a")], tmp_path, fresh=True)
|
|
178
|
+
assert _ids(data) == {"c/test_a.py::a"} # clean slate — test_b orphan cleared
|
|
179
|
+
assert "test_b" not in md
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def test_corrupt_prior_degrades_to_this_run(tmp_path):
|
|
183
|
+
(tmp_path / "grounding_report.json").write_text("not json at all", encoding="utf-8")
|
|
184
|
+
data, _ = _run([_rec("c/test_a.py::a")], tmp_path)
|
|
185
|
+
assert _ids(data) == {"c/test_a.py::a"} # didn't crash; wrote this run's records
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def test_output_is_deterministic(tmp_path):
|
|
189
|
+
recs = [_rec("c/test_b.py::b"), _rec("c/test_a.py::a")]
|
|
190
|
+
data1, md1 = _run(recs, tmp_path)
|
|
191
|
+
# re-running the identical set is idempotent (merge of a set onto itself)
|
|
192
|
+
data2, md2 = _run(recs, tmp_path)
|
|
193
|
+
assert data1 == data2 and md1 == md2
|
|
194
|
+
assert [c["id"] for c in data1["claims"]] == ["c/test_a.py::a", "c/test_b.py::b"]
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def test_empty_run_writes_nothing(tmp_path):
|
|
198
|
+
# no records collected → writer returns early, leaves any existing report untouched
|
|
199
|
+
P.pytest_sessionfinish(_FakeSession(_FakeConfig([], tmp_path)))
|
|
200
|
+
assert not (tmp_path / "grounding_report.json").exists()
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
# --------------------------------------------------------------------------- #
|
|
204
|
+
# end-to-end via pytester — the merge holds across two real pytest invocations
|
|
205
|
+
# --------------------------------------------------------------------------- #
|
|
206
|
+
def test_partial_run_preserves_other_files_pytester(pytester):
|
|
207
|
+
"""Two real pytest runs against the installed plugin: seed both files, then re-run
|
|
208
|
+
only one — the other file's claim must survive in the merged report."""
|
|
209
|
+
pytester.makepyfile(test_a='''
|
|
210
|
+
from grounding import statement
|
|
211
|
+
|
|
212
|
+
def test_a1():
|
|
213
|
+
statement("claim from file A")
|
|
214
|
+
assert True
|
|
215
|
+
''')
|
|
216
|
+
pytester.makepyfile(test_b='''
|
|
217
|
+
from grounding import statement
|
|
218
|
+
|
|
219
|
+
def test_b1():
|
|
220
|
+
statement("claim from file B")
|
|
221
|
+
assert True
|
|
222
|
+
''')
|
|
223
|
+
|
|
224
|
+
# full run: both files collected → report has both claims
|
|
225
|
+
r1 = pytester.runpytest_subprocess("--grounding-out", str(pytester.path))
|
|
226
|
+
r1.assert_outcomes(passed=2)
|
|
227
|
+
data = json.loads((pytester.path / "grounding_report.json").read_text())
|
|
228
|
+
assert {_test_file_of(c) for c in data["claims"]} == {"test_a.py", "test_b.py"}
|
|
229
|
+
|
|
230
|
+
# partial run: only test_a.py → test_b.py's claim is preserved via merge
|
|
231
|
+
r2 = pytester.runpytest_subprocess("test_a.py", "--grounding-out", str(pytester.path))
|
|
232
|
+
r2.assert_outcomes(passed=1)
|
|
233
|
+
data = json.loads((pytester.path / "grounding_report.json").read_text())
|
|
234
|
+
ids = {c["id"] for c in data["claims"]}
|
|
235
|
+
assert any(i.endswith("test_a.py::test_a1") for i in ids)
|
|
236
|
+
assert any(i.endswith("test_b.py::test_b1") for i in ids) # preserved!
|
|
237
|
+
|
|
238
|
+
# ...whereas --grounding-fresh drops the untouched file's claim
|
|
239
|
+
r3 = pytester.runpytest_subprocess(
|
|
240
|
+
"test_a.py", "--grounding-fresh", "--grounding-out", str(pytester.path))
|
|
241
|
+
r3.assert_outcomes(passed=1)
|
|
242
|
+
data = json.loads((pytester.path / "grounding_report.json").read_text())
|
|
243
|
+
ids = {c["id"] for c in data["claims"]}
|
|
244
|
+
assert any(i.endswith("test_a.py::test_a1") for i in ids)
|
|
245
|
+
assert not any(i.endswith("test_b.py::test_b1") for i in ids) # cleared
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _test_file_of(record):
|
|
249
|
+
return P._test_file_of(record)
|
|
@@ -2,6 +2,16 @@ from grounding._normalize import fold_match
|
|
|
2
2
|
from grounding import match_phrase
|
|
3
3
|
|
|
4
4
|
|
|
5
|
+
def test_normalizer_is_public_api():
|
|
6
|
+
# collapse_ws + fold_match are part of the public surface so consumers can share the
|
|
7
|
+
# one canonical fold (e.g. a verdict-cache identity that must agree with quote matching).
|
|
8
|
+
import grounding
|
|
9
|
+
|
|
10
|
+
assert {"collapse_ws", "fold_match", "match_phrase", "sha256"} <= set(grounding.__all__)
|
|
11
|
+
assert grounding.fold_match("Ube3a–dosage *gene*") == "Ube3a-dosage gene"
|
|
12
|
+
assert grounding.collapse_ws("a b\n c") == "a b c"
|
|
13
|
+
|
|
14
|
+
|
|
5
15
|
def test_fold_match_folds_dashes_markdown_and_whitespace():
|
|
6
16
|
assert fold_match("Ube3a–dosage *gene*") == "Ube3a-dosage gene"
|
|
7
17
|
|
|
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
|
{pytest_grounding-0.0.2 → pytest_grounding-0.0.4}/pytest_grounding.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
{pytest_grounding-0.0.2 → pytest_grounding-0.0.4}/pytest_grounding.egg-info/entry_points.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|