spantruth 0.3.0__py3-none-any.whl

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 (43) hide show
  1. spantruth/__init__.py +3 -0
  2. spantruth/calls.py +181 -0
  3. spantruth/checks.py +210 -0
  4. spantruth/cli.py +214 -0
  5. spantruth/conventions.py +128 -0
  6. spantruth/driver.py +168 -0
  7. spantruth/expectations.py +116 -0
  8. spantruth/fixtures/openai-responses/NOTICE.md +30 -0
  9. spantruth/fixtures/openai-responses/stream.missing-finish-abort.json +80 -0
  10. spantruth/known_issues.toml +52 -0
  11. spantruth/mockserver.py +121 -0
  12. spantruth/model.py +115 -0
  13. spantruth/paths.py +26 -0
  14. spantruth/receiver.py +164 -0
  15. spantruth/recorded.py +51 -0
  16. spantruth/reference.py +276 -0
  17. spantruth/report.py +179 -0
  18. spantruth/rules.py +91 -0
  19. spantruth/runner.py +244 -0
  20. spantruth/scenarios.py +353 -0
  21. spantruth/site.py +234 -0
  22. spantruth/subjects/_base.toml +20 -0
  23. spantruth/subjects/anthropic-sdk/instrument.py +9 -0
  24. spantruth/subjects/anthropic-sdk/subject.toml +7 -0
  25. spantruth/subjects/control-faulty/expected.toml +22 -0
  26. spantruth/subjects/control-faulty/instrument.py +9 -0
  27. spantruth/subjects/control-faulty/subject.toml +5 -0
  28. spantruth/subjects/control-honest/instrument.py +7 -0
  29. spantruth/subjects/control-honest/subject.toml +5 -0
  30. spantruth/subjects/openinference/instrument.py +9 -0
  31. spantruth/subjects/openinference/subject.toml +8 -0
  32. spantruth/subjects/openllmetry/instrument.py +9 -0
  33. spantruth/subjects/openllmetry/subject.toml +8 -0
  34. spantruth/subjects/otel-official/instrument.py +9 -0
  35. spantruth/subjects/otel-official/subject.toml +11 -0
  36. spantruth/subjects.py +191 -0
  37. spantruth/wire_anthropic.py +163 -0
  38. spantruth/wire_openai.py +178 -0
  39. spantruth-0.3.0.dist-info/METADATA +259 -0
  40. spantruth-0.3.0.dist-info/RECORD +43 -0
  41. spantruth-0.3.0.dist-info/WHEEL +4 -0
  42. spantruth-0.3.0.dist-info/entry_points.txt +2 -0
  43. spantruth-0.3.0.dist-info/licenses/LICENSE +202 -0
spantruth/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """spantruth: check LLM telemetry values against the provider wire."""
2
+
3
+ __version__ = "0.3.0"
spantruth/calls.py ADDED
@@ -0,0 +1,181 @@
1
+ """The app side of each scenario: how a normal application calls the provider SDK.
2
+
3
+ Runs inside a subject's virtual environment. Every function returns what the app saw, so the
4
+ runner can compare an instrumented run with an uninstrumented one.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ from typing import Any
11
+
12
+ from .model import Scenario
13
+
14
+ PROMPT = [{"role": "user", "content": "What is the capital of France?"}]
15
+ OPENAI_TOOLS = [
16
+ {
17
+ "type": "function",
18
+ "function": {
19
+ "name": name,
20
+ "parameters": {"type": "object", "properties": {key: {"type": "string"}}},
21
+ },
22
+ }
23
+ for name, key in (("get_weather", "city"), ("get_time", "zone"))
24
+ ]
25
+ ANTHROPIC_TOOLS = [
26
+ {"name": name, "input_schema": {"type": "object", "properties": {key: {"type": "string"}}}}
27
+ for name, key in (("get_weather", "city"), ("get_time", "zone"))
28
+ ]
29
+
30
+
31
+ class Seen:
32
+ """What the application observed. Compared between the baseline and instrumented runs."""
33
+
34
+ def __init__(self) -> None:
35
+ self.text = ""
36
+ self.events = 0
37
+ self.response_id: str | None = None
38
+ self.usage: dict | None = None
39
+
40
+ def as_dict(self) -> dict[str, Any]:
41
+ return {
42
+ "text": self.text,
43
+ "events": self.events,
44
+ "response_id": self.response_id,
45
+ "usage": self.usage,
46
+ }
47
+
48
+
49
+ def _openai_kwargs(sc: Scenario) -> dict:
50
+ kw: dict = {"model": sc.request_model, "messages": PROMPT}
51
+ if sc.tools:
52
+ kw["tools"] = OPENAI_TOOLS
53
+ if sc.stream:
54
+ kw["stream"] = True
55
+ if sc.stream and sc.stream_usage:
56
+ kw["stream_options"] = {"include_usage": True}
57
+ return kw
58
+
59
+
60
+ def _anthropic_kwargs(sc: Scenario) -> dict:
61
+ kw: dict = {"model": sc.request_model, "max_tokens": 1024, "messages": PROMPT}
62
+ if sc.tools:
63
+ kw["tools"] = ANTHROPIC_TOOLS
64
+ if sc.stream:
65
+ kw["stream"] = True
66
+ return kw
67
+
68
+
69
+ def _openai_result(r: Any, seen: Seen) -> None:
70
+ msg = r.choices[0].message
71
+ seen.text = (msg.content or "") + "".join(t.function.name for t in msg.tool_calls or ())
72
+ seen.response_id, seen.usage = r.id, r.usage.model_dump()
73
+
74
+
75
+ def _openai_chunk(c: Any, seen: Seen) -> None:
76
+ seen.events += 1
77
+ seen.response_id = c.id
78
+ if c.choices and c.choices[0].delta.content:
79
+ seen.text += c.choices[0].delta.content
80
+ if c.usage:
81
+ seen.usage = c.usage.model_dump()
82
+
83
+
84
+ def _responses_event(e: Any, seen: Seen) -> None:
85
+ seen.events += 1
86
+ if e.type == "response.created":
87
+ seen.response_id = e.response.id
88
+ elif e.type == "response.output_text.delta":
89
+ seen.text += e.delta
90
+ elif e.type == "response.completed" and e.response.usage:
91
+ seen.usage = e.response.usage.model_dump()
92
+
93
+
94
+ def _openai_create(client: Any, sc: Scenario):
95
+ """The call for the scenario's API, and how the app reads a stream from it."""
96
+ if sc.api == "responses":
97
+ kw: dict = {"model": sc.request_model, "input": PROMPT, "stream": sc.stream}
98
+ return client.responses.create(**kw), None, _responses_event
99
+ return client.chat.completions.create(**_openai_kwargs(sc)), _openai_result, _openai_chunk
100
+
101
+
102
+ def _anthropic_result(r: Any, seen: Seen) -> None:
103
+ seen.text = "".join(b.text if b.type == "text" else b.name for b in r.content)
104
+ seen.response_id, seen.usage = r.id, r.usage.model_dump()
105
+
106
+
107
+ def _anthropic_event(e: Any, seen: Seen) -> None:
108
+ seen.events += 1
109
+ if e.type == "message_start":
110
+ seen.response_id = e.message.id
111
+ elif e.type == "content_block_delta" and e.delta.type == "text_delta":
112
+ seen.text += e.delta.text
113
+
114
+
115
+ def run(sc: Scenario, base_url: str, seen: Seen) -> None:
116
+ """Run one scenario the way an application would. Exceptions propagate to the caller."""
117
+ if sc.asynchronous:
118
+ asyncio.run(_run_async(sc, base_url, seen))
119
+ return
120
+ if sc.provider == "openai":
121
+ import openai
122
+
123
+ client = openai.OpenAI(base_url=base_url, api_key="wt-test", max_retries=0, timeout=10)
124
+ result, on_result, on_event = _openai_create(client, sc)
125
+ else:
126
+ import anthropic
127
+
128
+ client = anthropic.Anthropic(
129
+ base_url=base_url, api_key="wt-test", max_retries=0, timeout=10
130
+ )
131
+ result = client.messages.create(**_anthropic_kwargs(sc))
132
+ on_result, on_event = _anthropic_result, _anthropic_event
133
+ if not sc.stream:
134
+ on_result(result, seen)
135
+ return
136
+ if sc.with_block:
137
+ with result as stream:
138
+ _read(stream, sc, seen, on_event)
139
+ return
140
+ _read(result, sc, seen, on_event)
141
+ result.close()
142
+
143
+
144
+ def _read(stream: Any, sc: Scenario, seen: Seen, on_event) -> None:
145
+ for event in stream:
146
+ on_event(event, seen)
147
+ if sc.read_events is not None and seen.events >= sc.read_events:
148
+ break
149
+
150
+
151
+ async def _run_async(sc: Scenario, base_url: str, seen: Seen) -> None:
152
+ if sc.provider == "openai":
153
+ import openai
154
+
155
+ client = openai.AsyncOpenAI(base_url=base_url, api_key="wt-test", max_retries=0, timeout=10)
156
+ pending, on_result, on_event = _openai_create(client, sc)
157
+ result = await pending
158
+ else:
159
+ import anthropic
160
+
161
+ client = anthropic.AsyncAnthropic(
162
+ base_url=base_url, api_key="wt-test", max_retries=0, timeout=10
163
+ )
164
+ result = await client.messages.create(**_anthropic_kwargs(sc))
165
+ on_result, on_event = _anthropic_result, _anthropic_event
166
+ if not sc.stream:
167
+ on_result(result, seen)
168
+ return
169
+ if sc.with_block:
170
+ async with result as stream:
171
+ await _aread(stream, sc, seen, on_event)
172
+ return
173
+ await _aread(result, sc, seen, on_event)
174
+ await result.close()
175
+
176
+
177
+ async def _aread(stream: Any, sc: Scenario, seen: Seen, on_event) -> None:
178
+ async for event in stream:
179
+ on_event(event, seen)
180
+ if sc.read_events is not None and seen.events >= sc.read_events:
181
+ break
spantruth/checks.py ADDED
@@ -0,0 +1,210 @@
1
+ """Grade one cell: what the app saw, and what the telemetry says, against the wire truth.
2
+
3
+ Pure functions over plain records, so every verdict is testable without a subprocess.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ from dataclasses import asdict, dataclass
10
+ from typing import Any
11
+
12
+ from .conventions import LLM_MARKERS, defined_for, lookup, openinference_only, same_finish
13
+ from .model import Scenario, Truth
14
+ from .receiver import SpanRecord
15
+ from .rules import RULES
16
+
17
+ # Ordered by severity. The cell takes the most severe verdict of its checks.
18
+ SEVERITY = ("n/a", "pass", "missing", "wrong", "lost", "broken", "inconclusive")
19
+ # A failing check is any check that did not pass and applies, `inconclusive` included.
20
+ FAILING = SEVERITY[2:]
21
+ TOKENS = (
22
+ "input_tokens",
23
+ "output_tokens",
24
+ "cache_read_tokens",
25
+ "cache_write_tokens",
26
+ "reasoning_tokens",
27
+ )
28
+ VALUES = ("request_model", "response_model", "response_id", "finish_reasons", *TOKENS)
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class Check:
33
+ id: str
34
+ verdict: str
35
+ expected: Any = None
36
+ got: Any = None
37
+ where: str | None = None
38
+ detail: str = ""
39
+
40
+ def as_dict(self) -> dict[str, Any]:
41
+ rule, url = RULES.get(self.id, ("", ""))
42
+ return asdict(self) | {"rule": rule, "rule_url": url}
43
+
44
+
45
+ def cell_verdict(checks: list[Check]) -> str:
46
+ return max((c.verdict for c in checks), key=SEVERITY.index, default="n/a")
47
+
48
+
49
+ def _app(base: dict, obs: dict) -> Check:
50
+ if obs.get("hang"):
51
+ return Check("app.unchanged", "broken", detail="the app hung until the watchdog fired")
52
+ diffs = []
53
+ if obs.get("exception") != base.get("exception"):
54
+ diffs.append(f"exception {base.get('exception')} became {obs.get('exception')}")
55
+ for key in ("text", "events", "response_id", "usage"):
56
+ if obs["seen"].get(key) != base["seen"].get(key):
57
+ diffs.append(f"{key} {base['seen'].get(key)!r} became {obs['seen'].get(key)!r}")
58
+ return Check("app.unchanged", "broken" if diffs else "pass", detail="; ".join(diffs))
59
+
60
+
61
+ def _formula(fact: str, got: int, t: Truth) -> str:
62
+ """Name the arithmetic a wrong token count most likely came from."""
63
+ read, write = t.cache_read_tokens or 0, t.cache_write_tokens or 0
64
+ if fact == "input_tokens" and t.input_tokens is not None:
65
+ total = t.input_tokens
66
+ guesses = {
67
+ total - read - write: "cache reads and writes left out",
68
+ total - write: "cache writes left out",
69
+ total - read: "cache reads left out",
70
+ total + read + write: "cache tokens counted twice",
71
+ 2 * total: "counted twice",
72
+ }
73
+ if got in guesses and read + write:
74
+ return guesses[got]
75
+ for other in TOKENS:
76
+ if other != fact and getattr(t, other) == got:
77
+ return f"equals {other.replace('_', ' ')}"
78
+ return ""
79
+
80
+
81
+ def _json_list(value: Any) -> list | None:
82
+ """The list inside a JSON string, for a library that encodes an array attribute as text."""
83
+ if not isinstance(value, str):
84
+ return None
85
+ try:
86
+ parsed = json.loads(value)
87
+ except ValueError:
88
+ return None
89
+ return parsed if isinstance(parsed, list) else None
90
+
91
+
92
+ def _value(fact: str, expected: Any, found: list[tuple[SpanRecord, str, Any]], t: Truth) -> Check:
93
+ if expected is None:
94
+ for _span, name, v in found:
95
+ if fact not in TOKENS or not v:
96
+ continue
97
+ if t.no_usage:
98
+ return Check(fact, "wrong", None, v, name, "estimated: the stream carried no usage")
99
+ if v in t.unread_counts:
100
+ return Check(
101
+ fact,
102
+ "wrong",
103
+ None,
104
+ v,
105
+ name,
106
+ "only the unread part of the stream carries this count",
107
+ )
108
+ return Check(fact, "n/a")
109
+ if not found:
110
+ return Check(fact, "missing", expected)
111
+ for _span, name, v in found:
112
+ ok = (
113
+ same_finish(expected, v)
114
+ if fact == "finish_reasons"
115
+ else (isinstance(v, (int, float)) and v == expected)
116
+ if fact in TOKENS
117
+ else v == expected
118
+ )
119
+ if not ok and fact == "response_model" and name == "llm.model_name":
120
+ ok = v == t.request_model # this name does not separate request and response model
121
+ if not ok:
122
+ detail = _formula(fact, v, t) if fact in TOKENS and isinstance(v, int) else ""
123
+ if fact == "finish_reasons" and same_finish(expected, _json_list(v)):
124
+ detail = (
125
+ f"the right values, recorded as the JSON string {v} instead of a string array"
126
+ )
127
+ return Check(fact, "wrong", expected, v, name, detail)
128
+ return Check(fact, "pass", expected, found[0][2], found[0][1])
129
+
130
+
131
+ def grade(
132
+ sc: Scenario, t: Truth, base: dict | None, obs: dict | None, spans: list[SpanRecord]
133
+ ) -> list[Check]:
134
+ if obs is None or base is None:
135
+ return [Check("harness", "inconclusive", detail="no observation was written")]
136
+ if "instrument_error" in obs:
137
+ # The subject's setup raised. That is a failing check of the subject, not a gap in the run.
138
+ return [Check("subject.instrument", "inconclusive", detail=obs["instrument_error"])]
139
+ checks = [_app(base, obs)]
140
+ if obs.get("hang"):
141
+ return checks
142
+ parent = obs["parent"]
143
+ llm = [s for s in spans if s.span_id != parent["span_id"] and LLM_MARKERS & s.attributes.keys()]
144
+ open_spans = [
145
+ r["name"]
146
+ for sid, r in obs.get("probe", {}).items()
147
+ if r["end_ns"] is None and sid != parent["span_id"]
148
+ ]
149
+ if open_spans:
150
+ checks.append(Check("span.ended", "lost", detail=f"never ended: {', '.join(open_spans)}"))
151
+ if not llm:
152
+ if not open_spans:
153
+ checks.append(Check("span.recorded", "missing", detail="no LLM span arrived"))
154
+ return checks
155
+ orphans = [s.name for s in llm if s.trace_id != parent["trace_id"]]
156
+ checks.append(
157
+ Check(
158
+ "span.parented",
159
+ "wrong" if orphans else "pass",
160
+ detail=f"outside the app's trace: {', '.join(orphans)}" if orphans else "",
161
+ )
162
+ )
163
+ if t.error is not None:
164
+ errored = [s for s in llm if s.status == "ERROR"]
165
+ checks.append(
166
+ Check(
167
+ "error.status",
168
+ "pass" if errored else "wrong",
169
+ "ERROR",
170
+ sorted({s.status for s in llm}),
171
+ )
172
+ )
173
+ typed = [s.attributes["error.type"] for s in llm if s.attributes.get("error.type")]
174
+ if typed:
175
+ checks.append(Check("error.type", "pass", got=typed[0]))
176
+ elif all(openinference_only(s.attributes) for s in llm):
177
+ checks.append(
178
+ Check(
179
+ "error.type",
180
+ "n/a",
181
+ detail="OpenInference records errors as span status and events",
182
+ )
183
+ )
184
+ else:
185
+ checks.append(Check("error.type", "missing"))
186
+ return checks
187
+ for fact in VALUES:
188
+ if not any(defined_for(s.attributes, fact) for s in llm):
189
+ checks.append(
190
+ Check(fact, "n/a", detail="not defined by the conventions this library follows")
191
+ )
192
+ continue
193
+ found = [(s, *hit) for s in llm if (hit := lookup(s.attributes, fact))]
194
+ checks.append(_value(fact, getattr(t, fact), found, t))
195
+ reporting = [
196
+ s.name
197
+ for s in llm
198
+ if lookup(s.attributes, "input_tokens") or lookup(s.attributes, "output_tokens")
199
+ ]
200
+ if len(reporting) > 1:
201
+ checks.append(
202
+ Check(
203
+ "tokens.single-report",
204
+ "wrong",
205
+ 1,
206
+ len(reporting),
207
+ detail=f"usage on {len(reporting)} spans: {', '.join(reporting)}",
208
+ )
209
+ )
210
+ return checks
spantruth/cli.py ADDED
@@ -0,0 +1,214 @@
1
+ """spantruth command line: list, run, report."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ import traceback
9
+ from pathlib import Path
10
+
11
+ from . import __version__, expectations, paths, report, runner, scenarios, subjects
12
+
13
+ # GitHub caps a step summary at 1 MiB. Stay below it, and say where the rest is.
14
+ SUMMARY_LIMIT = 1_000_000
15
+
16
+
17
+ def _list(_: argparse.Namespace) -> int:
18
+ print("Scenarios:")
19
+ for sid, sc in scenarios.SCENARIOS.items():
20
+ print(f" {sid:38} {sc.title}")
21
+ print(f"\nSubjects, from {subjects.SUBJECTS}:")
22
+ for name in subjects.all_names():
23
+ s = subjects.load(name)
24
+ print(f" {name:38} {s.title} ({', '.join(s.providers)})")
25
+ return 0
26
+
27
+
28
+ def _usage(message: object) -> int:
29
+ print(f"spantruth: {message}", file=sys.stderr)
30
+ return 2
31
+
32
+
33
+ def exit_code(doc: dict, differences: list[expectations.Difference]) -> int:
34
+ """3 when the harness could not run something, else 1 on a difference from the expectations."""
35
+ if runner.incomplete(doc):
36
+ return 3
37
+ return 1 if differences else 0
38
+
39
+
40
+ def _append_summary(path: Path, text: str) -> None:
41
+ if len(text.encode()) > SUMMARY_LIMIT:
42
+ text = text.encode()[:SUMMARY_LIMIT].decode(errors="ignore")
43
+ text += "\n\n... cut at 1,000,000 bytes. The results file has everything.\n"
44
+ with path.open("a") as f:
45
+ f.write(text + "\n")
46
+
47
+
48
+ def _run(args: argparse.Namespace) -> int:
49
+ try:
50
+ selected = [subjects.load(ref) for ref in args.subject or subjects.all_names()]
51
+ overrides = tuple(subjects.resolve_override(spec) for spec in args.overrides or ())
52
+ ids = args.scenario or list(scenarios.SCENARIOS)
53
+ for sid in ids:
54
+ scenarios.get(sid)
55
+ except subjects.SubjectError as exc:
56
+ return _usage(exc)
57
+ except KeyError as exc:
58
+ return _usage(exc.args[0])
59
+ if (args.expect or args.write_expect) and len(selected) != 1:
60
+ return _usage("--expect and --write-expect take exactly one --subject")
61
+ if (args.expect or args.write_expect) and not any(
62
+ scenarios.get(sid).provider in selected[0].providers for sid in ids
63
+ ):
64
+ # Without one cell, a comparison would pass on nothing.
65
+ return _usage(
66
+ f"no selected scenario applies to {selected[0].name}, which covers "
67
+ f"{', '.join(selected[0].providers)}"
68
+ )
69
+ try:
70
+ expected = expectations.load(Path(args.expect)) if args.expect else None
71
+ except expectations.ExpectationError as exc:
72
+ return _usage(exc)
73
+ names = [s.name for s in selected]
74
+ if twice := sorted({n for n in names if names.count(n) > 1}):
75
+ return _usage(f"two subjects named {', '.join(twice)}: rename one of the directories")
76
+ installed = {
77
+ subjects.package_name(r) for s in selected for r in subjects.requirements(s, False)
78
+ }
79
+ for spec in overrides:
80
+ if (name := subjects.package_name(spec)) not in installed:
81
+ return _usage(f"--with {spec}: no selected subject installs {name}")
82
+ ids = scenarios.with_dependencies(ids)
83
+ doc = runner.run(
84
+ selected, ids, Path(args.cache), latest=args.latest, jobs=args.jobs, overrides=overrides
85
+ )
86
+ out = Path(args.out)
87
+ out.parent.mkdir(parents=True, exist_ok=True)
88
+ out.write_text(json.dumps(doc, indent=1, sort_keys=True) + "\n")
89
+ text = report.markdown(doc)
90
+ gaps = runner.incomplete(doc)
91
+ differences: list[expectations.Difference] = []
92
+ if args.expect or args.write_expect:
93
+ result = doc["subjects"][selected[0].name]
94
+ found = expectations.failing(result)
95
+ if expected is not None and gaps:
96
+ text += "\n\nThe run is incomplete, so it was not compared with the expectation file."
97
+ elif expected is not None:
98
+ differences = expectations.compare(expected, found, ran=set(result["cells"]))
99
+ skipped = sorted(set(expected) - set(result["cells"]))
100
+ text += "\n\n" + expectations.describe(differences, skipped)
101
+ print(text)
102
+ print(f"\nwrote {out}")
103
+ if args.write_expect and gaps:
104
+ # A gap in the run is not a known failure of the library, so nothing is recorded.
105
+ print("spantruth: the run is incomplete, so --write-expect wrote nothing", file=sys.stderr)
106
+ elif args.write_expect:
107
+ header = (
108
+ f"Known failures of {selected[0].name}, written by spantruth {__version__}.\n"
109
+ "A run with --expect fails when the failing checks differ from this file."
110
+ )
111
+ written = Path(args.write_expect)
112
+ written.parent.mkdir(parents=True, exist_ok=True)
113
+ written.write_text(expectations.dumps(found, header))
114
+ print(f"wrote {written}")
115
+ if args.summary:
116
+ _append_summary(Path(args.summary), text)
117
+ for gap in gaps:
118
+ print(f"spantruth: incomplete run: {gap}", file=sys.stderr)
119
+ return exit_code(doc, differences)
120
+
121
+
122
+ def _report(args: argparse.Namespace) -> int:
123
+ doc = json.loads(Path(args.results).read_text())
124
+ if args.html:
125
+ Path(args.html).parent.mkdir(parents=True, exist_ok=True)
126
+ Path(args.html).write_text(report.html(doc))
127
+ print(f"wrote {args.html}")
128
+ if args.badges:
129
+ out = Path(args.badges)
130
+ out.mkdir(parents=True, exist_ok=True)
131
+ for name, badge in report.badges(doc).items():
132
+ (out / f"{name}.json").write_text(json.dumps(badge) + "\n")
133
+ print(f"wrote badges to {out}")
134
+ if args.markdown or not (args.html or args.badges):
135
+ print(report.markdown(doc))
136
+ return 0
137
+
138
+
139
+ def parser() -> argparse.ArgumentParser:
140
+ cache = paths.cache_dir()
141
+ parser = argparse.ArgumentParser(
142
+ prog="spantruth",
143
+ description="Check what LLM instrumentation reports against what the provider sent.",
144
+ )
145
+ sub = parser.add_subparsers(dest="command", required=True)
146
+ sub.add_parser("list", help="list scenarios and subjects").set_defaults(func=_list)
147
+ run = sub.add_parser("run", help="run subjects against scenarios")
148
+ run.add_argument(
149
+ "--subject",
150
+ action="append",
151
+ help="a built-in subject name, or a subject directory: a path with a slash, such as "
152
+ "./spantruth; repeatable",
153
+ )
154
+ run.add_argument("--scenario", action="append", help="scenario id, repeatable")
155
+ run.add_argument("--latest", action="store_true", help="unpin the subjects' own packages")
156
+ run.add_argument(
157
+ "--with",
158
+ dest="overrides",
159
+ action="append",
160
+ metavar="SPEC",
161
+ help="replace a package with this requirement: a version, a pull request git ref, or a "
162
+ "local checkout (a path with a slash); repeatable",
163
+ )
164
+ run.add_argument(
165
+ "--out",
166
+ default=str(cache / "results" / "latest.json"),
167
+ help="where to write the results document (default: %(default)s)",
168
+ )
169
+ run.add_argument(
170
+ "--cache",
171
+ default=str(cache / "venvs"),
172
+ help="where to keep the subject environments (default: %(default)s)",
173
+ )
174
+ run.add_argument("--jobs", type=int, default=4)
175
+ run.add_argument(
176
+ "--expect",
177
+ metavar="FILE",
178
+ help="compare the failing checks of the one selected subject with this file, and exit 1 "
179
+ "when they differ",
180
+ )
181
+ run.add_argument(
182
+ "--write-expect",
183
+ metavar="FILE",
184
+ help="write the failing checks of the one selected subject to this file",
185
+ )
186
+ run.add_argument(
187
+ "--summary",
188
+ metavar="FILE",
189
+ help="append the Markdown report to this file, such as $GITHUB_STEP_SUMMARY",
190
+ )
191
+ run.set_defaults(func=_run)
192
+ rep = sub.add_parser("report", help="render a results document")
193
+ rep.add_argument("results")
194
+ rep.add_argument("--html", help="write a static HTML report to this path")
195
+ rep.add_argument(
196
+ "--badges", help="write a shields.io endpoint JSON per library to this directory"
197
+ )
198
+ rep.add_argument("--markdown", action="store_true", help="print the Markdown matrix")
199
+ rep.set_defaults(func=_report)
200
+ return parser
201
+
202
+
203
+ def main(argv: list[str] | None = None) -> int:
204
+ args = parser().parse_args(argv)
205
+ try:
206
+ return args.func(args)
207
+ except Exception as exc: # unexpected means the harness failed, never a verdict (exit 3)
208
+ traceback.print_exc()
209
+ print(f"spantruth: could not finish: {type(exc).__name__}: {exc}", file=sys.stderr)
210
+ return 3
211
+
212
+
213
+ if __name__ == "__main__":
214
+ sys.exit(main())