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.
Files changed (28) hide show
  1. {pytest_grounding-0.0.2/pytest_grounding.egg-info → pytest_grounding-0.0.4}/PKG-INFO +1 -1
  2. {pytest_grounding-0.0.2 → pytest_grounding-0.0.4}/grounding/__init__.py +3 -2
  3. {pytest_grounding-0.0.2 → pytest_grounding-0.0.4}/pyproject.toml +1 -1
  4. {pytest_grounding-0.0.2 → pytest_grounding-0.0.4/pytest_grounding.egg-info}/PKG-INFO +1 -1
  5. {pytest_grounding-0.0.2 → pytest_grounding-0.0.4}/pytest_grounding.egg-info/SOURCES.txt +1 -0
  6. pytest_grounding-0.0.4/tests/test_merge.py +249 -0
  7. {pytest_grounding-0.0.2 → pytest_grounding-0.0.4}/tests/test_text.py +10 -0
  8. {pytest_grounding-0.0.2 → pytest_grounding-0.0.4}/LICENSE +0 -0
  9. {pytest_grounding-0.0.2 → pytest_grounding-0.0.4}/README.md +0 -0
  10. {pytest_grounding-0.0.2 → pytest_grounding-0.0.4}/grounding/_capture.py +0 -0
  11. {pytest_grounding-0.0.2 → pytest_grounding-0.0.4}/grounding/_normalize.py +0 -0
  12. {pytest_grounding-0.0.2 → pytest_grounding-0.0.4}/grounding/_text.py +0 -0
  13. {pytest_grounding-0.0.2 → pytest_grounding-0.0.4}/grounding/claim.py +0 -0
  14. {pytest_grounding-0.0.2 → pytest_grounding-0.0.4}/grounding/cli.py +0 -0
  15. {pytest_grounding-0.0.2 → pytest_grounding-0.0.4}/grounding/guard.py +0 -0
  16. {pytest_grounding-0.0.2 → pytest_grounding-0.0.4}/grounding/loaders.py +0 -0
  17. {pytest_grounding-0.0.2 → pytest_grounding-0.0.4}/grounding/plugin.py +0 -0
  18. {pytest_grounding-0.0.2 → pytest_grounding-0.0.4}/grounding/report_io.py +0 -0
  19. {pytest_grounding-0.0.2 → pytest_grounding-0.0.4}/grounding/trace.py +0 -0
  20. {pytest_grounding-0.0.2 → pytest_grounding-0.0.4}/pytest_grounding.egg-info/dependency_links.txt +0 -0
  21. {pytest_grounding-0.0.2 → pytest_grounding-0.0.4}/pytest_grounding.egg-info/entry_points.txt +0 -0
  22. {pytest_grounding-0.0.2 → pytest_grounding-0.0.4}/pytest_grounding.egg-info/requires.txt +0 -0
  23. {pytest_grounding-0.0.2 → pytest_grounding-0.0.4}/pytest_grounding.egg-info/top_level.txt +0 -0
  24. {pytest_grounding-0.0.2 → pytest_grounding-0.0.4}/setup.cfg +0 -0
  25. {pytest_grounding-0.0.2 → pytest_grounding-0.0.4}/tests/test_capture.py +0 -0
  26. {pytest_grounding-0.0.2 → pytest_grounding-0.0.4}/tests/test_loaders.py +0 -0
  27. {pytest_grounding-0.0.2 → pytest_grounding-0.0.4}/tests/test_plugin.py +0 -0
  28. {pytest_grounding-0.0.2 → pytest_grounding-0.0.4}/tests/test_trace.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pytest-grounding
3
- Version: 0.0.2
3
+ Version: 0.0.4
4
4
  Summary: Turn assertions about data into re-runnable, provenance-tracked claims — written and reviewed by agents.
5
5
  Author-email: Sam Quigley <quigley@emerose.com>
6
6
  License: MIT
@@ -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.2"
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.2"
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"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pytest-grounding
3
- Version: 0.0.2
3
+ Version: 0.0.4
4
4
  Summary: Turn assertions about data into re-runnable, provenance-tracked claims — written and reviewed by agents.
5
5
  Author-email: Sam Quigley <quigley@emerose.com>
6
6
  License: MIT
@@ -20,6 +20,7 @@ pytest_grounding.egg-info/requires.txt
20
20
  pytest_grounding.egg-info/top_level.txt
21
21
  tests/test_capture.py
22
22
  tests/test_loaders.py
23
+ tests/test_merge.py
23
24
  tests/test_plugin.py
24
25
  tests/test_text.py
25
26
  tests/test_trace.py
@@ -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