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.
- spantruth/__init__.py +3 -0
- spantruth/calls.py +181 -0
- spantruth/checks.py +210 -0
- spantruth/cli.py +214 -0
- spantruth/conventions.py +128 -0
- spantruth/driver.py +168 -0
- spantruth/expectations.py +116 -0
- spantruth/fixtures/openai-responses/NOTICE.md +30 -0
- spantruth/fixtures/openai-responses/stream.missing-finish-abort.json +80 -0
- spantruth/known_issues.toml +52 -0
- spantruth/mockserver.py +121 -0
- spantruth/model.py +115 -0
- spantruth/paths.py +26 -0
- spantruth/receiver.py +164 -0
- spantruth/recorded.py +51 -0
- spantruth/reference.py +276 -0
- spantruth/report.py +179 -0
- spantruth/rules.py +91 -0
- spantruth/runner.py +244 -0
- spantruth/scenarios.py +353 -0
- spantruth/site.py +234 -0
- spantruth/subjects/_base.toml +20 -0
- spantruth/subjects/anthropic-sdk/instrument.py +9 -0
- spantruth/subjects/anthropic-sdk/subject.toml +7 -0
- spantruth/subjects/control-faulty/expected.toml +22 -0
- spantruth/subjects/control-faulty/instrument.py +9 -0
- spantruth/subjects/control-faulty/subject.toml +5 -0
- spantruth/subjects/control-honest/instrument.py +7 -0
- spantruth/subjects/control-honest/subject.toml +5 -0
- spantruth/subjects/openinference/instrument.py +9 -0
- spantruth/subjects/openinference/subject.toml +8 -0
- spantruth/subjects/openllmetry/instrument.py +9 -0
- spantruth/subjects/openllmetry/subject.toml +8 -0
- spantruth/subjects/otel-official/instrument.py +9 -0
- spantruth/subjects/otel-official/subject.toml +11 -0
- spantruth/subjects.py +191 -0
- spantruth/wire_anthropic.py +163 -0
- spantruth/wire_openai.py +178 -0
- spantruth-0.3.0.dist-info/METADATA +259 -0
- spantruth-0.3.0.dist-info/RECORD +43 -0
- spantruth-0.3.0.dist-info/WHEEL +4 -0
- spantruth-0.3.0.dist-info/entry_points.txt +2 -0
- spantruth-0.3.0.dist-info/licenses/LICENSE +202 -0
spantruth/__init__.py
ADDED
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())
|