qe-harness 0.1.0__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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Shan Konduru
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,143 @@
1
+ Metadata-Version: 2.4
2
+ Name: qe-harness
3
+ Version: 0.1.0
4
+ Summary: Black-box, five-stage validation of conversation-to-action AI assistants: intent, orchestration, MCP tool calls, backend state and user response
5
+ Author: Shan Konduru
6
+ License-Expression: MIT
7
+ Keywords: testing,qa,llm,agents,mcp,evaluation,chatbot
8
+ Classifier: Development Status :: 3 - Alpha
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: Operating System :: OS Independent
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.11
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Programming Language :: Python :: 3.13
15
+ Classifier: Topic :: Software Development :: Quality Assurance
16
+ Classifier: Topic :: Software Development :: Testing
17
+ Requires-Python: >=3.11
18
+ Description-Content-Type: text/markdown
19
+ License-File: LICENSE
20
+ Requires-Dist: mcp>=2.3
21
+ Requires-Dist: pydantic>=2.7
22
+ Requires-Dist: pyyaml>=6
23
+ Requires-Dist: python-dotenv>=1.0
24
+ Requires-Dist: websockets>=12
25
+ Provides-Extra: azure
26
+ Requires-Dist: openai>=1.50; extra == "azure"
27
+ Provides-Extra: anthropic
28
+ Requires-Dist: anthropic>=2.4; extra == "anthropic"
29
+ Provides-Extra: judge
30
+ Requires-Dist: openai>=1.50; extra == "judge"
31
+ Requires-Dist: anthropic>=2.4; extra == "judge"
32
+ Dynamic: license-file
33
+
34
+ # qe-harness
35
+
36
+ Black-box, five-stage validation of conversation-to-action AI assistants.
37
+
38
+ A chat-only test sees the final reply. `qe-harness` tests every turn at five stages, from the outside:
39
+
40
+ **intent → orchestration → MCP tool calls → backend state → user response**
41
+
42
+ It never imports the application under test. It signs in as test users and chats over the app's API, reads the
43
+ app's per-turn traces, records every tool call independently through its own MCP proxy, and compares read-only
44
+ snapshots of the business data before and after each turn. So it catches what a polite reply hides: a write the
45
+ customer never approved, an amount no tool returned, a reply that says "done" when nothing was saved.
46
+
47
+ ## Install
48
+
49
+ ```bash
50
+ pip install qe-harness # core
51
+ pip install "qe-harness[judge]" # plus the optional LLM grader (Azure OpenAI or Anthropic)
52
+ ```
53
+
54
+ Python 3.11 or newer.
55
+
56
+ ## Quick start
57
+
58
+ ```bash
59
+ qe init # writes qe.yaml (sandbox connection) and qe_catalog.yaml (test cases) in this folder
60
+ # edit both files, then provide the two secrets the config names:
61
+ # QE_SANDBOX_TOKEN sandbox control token
62
+ # QE_TEST_PASSWORD test users' password
63
+ # (environment variables, or a .env file next to qe.yaml)
64
+ qe doctor # connection checks: all green means testing can start
65
+ qe run --open # run every suite, evaluate each turn, open the HTML report
66
+ ```
67
+
68
+ `qe` finds its configuration from `--config PATH`, then `$QE_CONFIG`, then `qe.yaml` in the working directory.
69
+ Relative paths in it start from the folder holding the file (or from `base_dir:` if set).
70
+
71
+ ## What the app's sandbox must provide
72
+
73
+ | Need | Endpoint or setting |
74
+ |---|---|
75
+ | Sign-in as test users | `POST /api/login` `{"username", "password"}`, session cookie |
76
+ | Conversations | `POST /api/conversations` → `{"id"}`; WebSocket `/ws?conversation=<id>` with messages `{"type": "user", "text"}` and `{"type": "confirm", "key", "approved"}`; events `ready`, `awaiting_confirmation`, `assistant_message`, `error`, `turn_completed` |
77
+ | Per-turn traces | `GET /api/conversations/<id>/traces`: intent (`primary_intent`, `intents`), plan steps, tool calls with arguments and outcomes |
78
+ | File uploads (optional) | `POST /api/conversations/<id>/documents?filename=…` |
79
+ | Control | `GET /sandbox/status` (versions, clock, toggles) and `POST /sandbox/reset`, with header `x-sandbox-token` |
80
+ | Independent tool record | Start the MCP tool server through `python -m qe_harness.mcp_proxy --log-dir <dir> -- <server command>` |
81
+ | Business data | A read-only SQLite replica the harness can snapshot |
82
+
83
+ ## Commands
84
+
85
+ | Command | What it does |
86
+ |---|---|
87
+ | `qe init [--dir D] [--force]` | Starter `qe.yaml` and `qe_catalog.yaml` |
88
+ | `qe doctor [--quick]` | Network, control API, clock, versions, proxy toggle, reset, read-back, sign-in, round trip, trace link, tool capture |
89
+ | `qe status` | Sandbox versions, clock and toggles |
90
+ | `qe cases [--suite S]` | List catalog cases |
91
+ | `qe run [--suite S…] [--case ID] [--repeats N] [--judge] [--open]` | Reset the sandbox, run the suites, evaluate every turn, write evidence and the HTML report |
92
+ | `qe runs` | Recent runs |
93
+ | `qe show RUN CASE` | Evidence for one case: message, approvals, intent, plan, trace and proxy tool calls, data changes, reply, verdicts |
94
+ | `qe report RUN [--open]` | Rebuild the self-contained HTML report |
95
+ | `qe coverage [RUN] [--trend]` | Intent coverage for a run, or per intent across runs |
96
+ | `qe mcp serve` | Run the harness as an MCP server for AI assistants |
97
+
98
+ ## What is checked per turn
99
+
100
+ | Stage | Checks |
101
+ |---|---|
102
+ | Intent | The router's primary intent is one of the expected intents |
103
+ | Orchestration | Writes only after approval; no unexpected approval prompts; no repeated identical calls |
104
+ | MCP tool call | Required calls succeeded with the expected arguments; forbidden calls absent; **trace and proxy agree** |
105
+ | Backend state | Only the expected tables changed; SQL checks; **declined → nothing changed; data changed → a write succeeded** |
106
+ | User response | Required facts present, forbidden phrases absent, optional LLM grader for consistency with what the tools did |
107
+
108
+ Each failure records its first failing stage, so developers know where to look. With `--repeats N`, every case gets
109
+ a pass rate with a 95% Wilson range and unstable cases are reported as flaky.
110
+
111
+ ## Intent coverage
112
+
113
+ List the app's intent families under `intents:` in `qe.yaml`. Each report shows, per family, the cases that test it,
114
+ their pass rate and a status (covered, thin, failing, not tested), plus a families × runs grid of how coverage
115
+ changes over time. `qe coverage --trend` shows the same in the terminal.
116
+
117
+ ## In AI assistants (MCP)
118
+
119
+ ```bash
120
+ claude mcp add qe-harness -- qe mcp serve
121
+ ```
122
+
123
+ For Cursor or VS Code, add a stdio server with command `qe` and args `["mcp", "serve"]`.
124
+
125
+ Tools: `sandbox_status`, `sandbox_doctor`, `list_cases`, `get_case`, `run_tests` (changes state; capped per call),
126
+ `get_run_status`, `list_runs`, `get_failure` (masked evidence, marked as untrusted data), `get_intent_coverage`,
127
+ `get_intent_trend`. Prompt: `triage_run`.
128
+
129
+ ## LLM grader (optional)
130
+
131
+ `qe run --judge` asks a model whether each reply is consistent with what the tools actually did. It uses its own
132
+ settings, separate from the app's: `QE_JUDGE_PROVIDER` = `AZURE` (with `QE_JUDGE_AZURE_API_KEY`,
133
+ `QE_JUDGE_AZURE_ENDPOINT`, `QE_JUDGE_AZURE_DEPLOYMENT`) or `ANTHROPIC` (with `ANTHROPIC_API_KEY`, optional
134
+ `QE_JUDGE_MODEL`).
135
+
136
+ ## Status
137
+
138
+ Alpha. Not yet included: fault injection, load and soak testing, simulated customers, robustness variant
139
+ generation and a release gate.
140
+
141
+ ## License
142
+
143
+ MIT
@@ -0,0 +1,110 @@
1
+ # qe-harness
2
+
3
+ Black-box, five-stage validation of conversation-to-action AI assistants.
4
+
5
+ A chat-only test sees the final reply. `qe-harness` tests every turn at five stages, from the outside:
6
+
7
+ **intent → orchestration → MCP tool calls → backend state → user response**
8
+
9
+ It never imports the application under test. It signs in as test users and chats over the app's API, reads the
10
+ app's per-turn traces, records every tool call independently through its own MCP proxy, and compares read-only
11
+ snapshots of the business data before and after each turn. So it catches what a polite reply hides: a write the
12
+ customer never approved, an amount no tool returned, a reply that says "done" when nothing was saved.
13
+
14
+ ## Install
15
+
16
+ ```bash
17
+ pip install qe-harness # core
18
+ pip install "qe-harness[judge]" # plus the optional LLM grader (Azure OpenAI or Anthropic)
19
+ ```
20
+
21
+ Python 3.11 or newer.
22
+
23
+ ## Quick start
24
+
25
+ ```bash
26
+ qe init # writes qe.yaml (sandbox connection) and qe_catalog.yaml (test cases) in this folder
27
+ # edit both files, then provide the two secrets the config names:
28
+ # QE_SANDBOX_TOKEN sandbox control token
29
+ # QE_TEST_PASSWORD test users' password
30
+ # (environment variables, or a .env file next to qe.yaml)
31
+ qe doctor # connection checks: all green means testing can start
32
+ qe run --open # run every suite, evaluate each turn, open the HTML report
33
+ ```
34
+
35
+ `qe` finds its configuration from `--config PATH`, then `$QE_CONFIG`, then `qe.yaml` in the working directory.
36
+ Relative paths in it start from the folder holding the file (or from `base_dir:` if set).
37
+
38
+ ## What the app's sandbox must provide
39
+
40
+ | Need | Endpoint or setting |
41
+ |---|---|
42
+ | Sign-in as test users | `POST /api/login` `{"username", "password"}`, session cookie |
43
+ | Conversations | `POST /api/conversations` → `{"id"}`; WebSocket `/ws?conversation=<id>` with messages `{"type": "user", "text"}` and `{"type": "confirm", "key", "approved"}`; events `ready`, `awaiting_confirmation`, `assistant_message`, `error`, `turn_completed` |
44
+ | Per-turn traces | `GET /api/conversations/<id>/traces`: intent (`primary_intent`, `intents`), plan steps, tool calls with arguments and outcomes |
45
+ | File uploads (optional) | `POST /api/conversations/<id>/documents?filename=…` |
46
+ | Control | `GET /sandbox/status` (versions, clock, toggles) and `POST /sandbox/reset`, with header `x-sandbox-token` |
47
+ | Independent tool record | Start the MCP tool server through `python -m qe_harness.mcp_proxy --log-dir <dir> -- <server command>` |
48
+ | Business data | A read-only SQLite replica the harness can snapshot |
49
+
50
+ ## Commands
51
+
52
+ | Command | What it does |
53
+ |---|---|
54
+ | `qe init [--dir D] [--force]` | Starter `qe.yaml` and `qe_catalog.yaml` |
55
+ | `qe doctor [--quick]` | Network, control API, clock, versions, proxy toggle, reset, read-back, sign-in, round trip, trace link, tool capture |
56
+ | `qe status` | Sandbox versions, clock and toggles |
57
+ | `qe cases [--suite S]` | List catalog cases |
58
+ | `qe run [--suite S…] [--case ID] [--repeats N] [--judge] [--open]` | Reset the sandbox, run the suites, evaluate every turn, write evidence and the HTML report |
59
+ | `qe runs` | Recent runs |
60
+ | `qe show RUN CASE` | Evidence for one case: message, approvals, intent, plan, trace and proxy tool calls, data changes, reply, verdicts |
61
+ | `qe report RUN [--open]` | Rebuild the self-contained HTML report |
62
+ | `qe coverage [RUN] [--trend]` | Intent coverage for a run, or per intent across runs |
63
+ | `qe mcp serve` | Run the harness as an MCP server for AI assistants |
64
+
65
+ ## What is checked per turn
66
+
67
+ | Stage | Checks |
68
+ |---|---|
69
+ | Intent | The router's primary intent is one of the expected intents |
70
+ | Orchestration | Writes only after approval; no unexpected approval prompts; no repeated identical calls |
71
+ | MCP tool call | Required calls succeeded with the expected arguments; forbidden calls absent; **trace and proxy agree** |
72
+ | Backend state | Only the expected tables changed; SQL checks; **declined → nothing changed; data changed → a write succeeded** |
73
+ | User response | Required facts present, forbidden phrases absent, optional LLM grader for consistency with what the tools did |
74
+
75
+ Each failure records its first failing stage, so developers know where to look. With `--repeats N`, every case gets
76
+ a pass rate with a 95% Wilson range and unstable cases are reported as flaky.
77
+
78
+ ## Intent coverage
79
+
80
+ List the app's intent families under `intents:` in `qe.yaml`. Each report shows, per family, the cases that test it,
81
+ their pass rate and a status (covered, thin, failing, not tested), plus a families × runs grid of how coverage
82
+ changes over time. `qe coverage --trend` shows the same in the terminal.
83
+
84
+ ## In AI assistants (MCP)
85
+
86
+ ```bash
87
+ claude mcp add qe-harness -- qe mcp serve
88
+ ```
89
+
90
+ For Cursor or VS Code, add a stdio server with command `qe` and args `["mcp", "serve"]`.
91
+
92
+ Tools: `sandbox_status`, `sandbox_doctor`, `list_cases`, `get_case`, `run_tests` (changes state; capped per call),
93
+ `get_run_status`, `list_runs`, `get_failure` (masked evidence, marked as untrusted data), `get_intent_coverage`,
94
+ `get_intent_trend`. Prompt: `triage_run`.
95
+
96
+ ## LLM grader (optional)
97
+
98
+ `qe run --judge` asks a model whether each reply is consistent with what the tools actually did. It uses its own
99
+ settings, separate from the app's: `QE_JUDGE_PROVIDER` = `AZURE` (with `QE_JUDGE_AZURE_API_KEY`,
100
+ `QE_JUDGE_AZURE_ENDPOINT`, `QE_JUDGE_AZURE_DEPLOYMENT`) or `ANTHROPIC` (with `ANTHROPIC_API_KEY`, optional
101
+ `QE_JUDGE_MODEL`).
102
+
103
+ ## Status
104
+
105
+ Alpha. Not yet included: fault injection, load and soak testing, simulated customers, robustness variant
106
+ generation and a release gate.
107
+
108
+ ## License
109
+
110
+ MIT
@@ -0,0 +1,52 @@
1
+ # Package definition for publishing the QE harness on its own as `qe-harness`.
2
+ # Build with: python tools/build_harness.py (stages qe_harness/ next to this file, then builds sdist + wheel)
3
+ [build-system]
4
+ requires = ["setuptools>=77"]
5
+ build-backend = "setuptools.build_meta"
6
+
7
+ [project]
8
+ name = "qe-harness"
9
+ dynamic = ["version"]
10
+ description = "Black-box, five-stage validation of conversation-to-action AI assistants: intent, orchestration, MCP tool calls, backend state and user response"
11
+ readme = "README.md"
12
+ requires-python = ">=3.11"
13
+ license = "MIT"
14
+ license-files = ["LICENSE"]
15
+ authors = [{ name = "Shan Konduru" }]
16
+ keywords = ["testing", "qa", "llm", "agents", "mcp", "evaluation", "chatbot"]
17
+ classifiers = [
18
+ "Development Status :: 3 - Alpha",
19
+ "Intended Audience :: Developers",
20
+ "Operating System :: OS Independent",
21
+ "Programming Language :: Python :: 3",
22
+ "Programming Language :: Python :: 3.11",
23
+ "Programming Language :: Python :: 3.12",
24
+ "Programming Language :: Python :: 3.13",
25
+ "Topic :: Software Development :: Quality Assurance",
26
+ "Topic :: Software Development :: Testing",
27
+ ]
28
+ dependencies = [
29
+ "mcp>=2.3",
30
+ "pydantic>=2.7",
31
+ "pyyaml>=6",
32
+ "python-dotenv>=1.0",
33
+ "websockets>=12",
34
+ ]
35
+
36
+ [project.optional-dependencies]
37
+ # The optional LLM grader (qe run --judge) calls Azure OpenAI or Anthropic.
38
+ azure = ["openai>=1.50"]
39
+ anthropic = ["anthropic>=2.4"]
40
+ judge = ["openai>=1.50", "anthropic>=2.4"]
41
+
42
+ [project.scripts]
43
+ qe = "qe_harness.cli:main"
44
+
45
+ [tool.setuptools]
46
+ packages = ["qe_harness"]
47
+
48
+ [tool.setuptools.package-data]
49
+ qe_harness = ["templates/*.yaml"]
50
+
51
+ [tool.setuptools.dynamic]
52
+ version = { attr = "qe_harness.__version__" }
@@ -0,0 +1,12 @@
1
+ """QE validation harness (prototype).
2
+
3
+ Tests a conversation-to-action assistant from the outside, at five stages:
4
+ intent, orchestration, MCP tool calls, backend state and user response.
5
+
6
+ The harness never imports the application: it signs in and chats over the app's
7
+ public API, reads traces through the sandbox's trace API, captures tool calls
8
+ through its own recording proxy, and reads business data through a read-only
9
+ connection - exactly what a QE partner gets from a client sandbox.
10
+ """
11
+
12
+ __version__ = "0.1.0"
@@ -0,0 +1,3 @@
1
+ from qe_harness.cli import main
2
+
3
+ main()
@@ -0,0 +1,101 @@
1
+ """Evidence capture outside the conversation: proxy tool-call log and read-only state snapshots."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import sqlite3
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from qe_harness.config import HarnessConfig
12
+
13
+
14
+ # ------------------------------------------------------------------ proxy log
15
+ def proxy_calls(config: HarnessConfig, customer_id: str, start: float, end: float) -> list[dict[str, Any]]:
16
+ """Tool calls the recording proxy saw for one customer in a time window, with their outcome."""
17
+ requests: dict[Any, dict] = {}
18
+ responses: dict[Any, dict] = {}
19
+ if not config.proxy_log_dir.exists():
20
+ return []
21
+ for path in config.proxy_log_dir.glob(f"{customer_id}-*.jsonl"):
22
+ for line in path.read_text(encoding="utf-8").splitlines():
23
+ try:
24
+ entry = json.loads(line)
25
+ except ValueError:
26
+ continue
27
+ if not (start - 0.5 <= entry["ts"] <= end + 0.5):
28
+ continue
29
+ msg = entry["message"]
30
+ key = (path.name, msg.get("id"))
31
+ if entry["direction"] == "request":
32
+ requests[key] = {"ts": entry["ts"], **msg}
33
+ else:
34
+ responses[key] = msg
35
+ calls = []
36
+ for key, req in sorted(requests.items(), key=lambda kv: kv[1]["ts"]):
37
+ params = req.get("params", {})
38
+ resp = responses.get(key, {})
39
+ result = resp.get("result", {}) or {}
40
+ text = "\n".join(c.get("text", "") for c in result.get("content", []) if isinstance(c, dict))
41
+ calls.append({"name": params.get("name"), "arguments": params.get("arguments") or {},
42
+ "is_error": bool(result.get("isError") or result.get("is_error") or "error" in resp),
43
+ "result": text[:20000], "ts": req["ts"]})
44
+ return calls
45
+
46
+
47
+ # ------------------------------------------------------------------ snapshots
48
+ def snapshot(config: HarnessConfig) -> dict[str, dict[str, dict[str, Any]]]:
49
+ """Business data keyed by table and primary key, through a read-only connection."""
50
+ uri = f"file:{config.sor_path.as_posix()}?mode=ro"
51
+ con = sqlite3.connect(uri, uri=True)
52
+ con.row_factory = sqlite3.Row
53
+ out: dict[str, dict[str, dict[str, Any]]] = {}
54
+ try:
55
+ for table in config.sor_tables:
56
+ ignore = set(config.sor_ignore_columns.get(table, []))
57
+ try:
58
+ rows = con.execute(f'SELECT * FROM "{table}"').fetchall()
59
+ except sqlite3.OperationalError:
60
+ out[table] = {}
61
+ continue
62
+ data = {}
63
+ for r in rows:
64
+ d = dict(r)
65
+ for col in ignore & set(d): # keep a fingerprint instead of large content
66
+ val = d.pop(col)
67
+ d[col + "_sha256"] = hashlib.sha256(val if isinstance(val, bytes) else str(val).encode()).hexdigest()[:16]
68
+ data[str(d.get("id", json.dumps(d, sort_keys=True, default=str)))] = d
69
+ out[table] = data
70
+ finally:
71
+ con.close()
72
+ return out
73
+
74
+
75
+ def diff(before: dict, after: dict) -> dict[str, Any]:
76
+ """Per-table added / removed / changed rows between two snapshots."""
77
+ result: dict[str, Any] = {}
78
+ for table in sorted(set(before) | set(after)):
79
+ b, a = before.get(table, {}), after.get(table, {})
80
+ added = [a[k] for k in a.keys() - b.keys()]
81
+ removed = [b[k] for k in b.keys() - a.keys()]
82
+ changed = {}
83
+ for k in a.keys() & b.keys():
84
+ cols = {c: [b[k].get(c), a[k].get(c)] for c in set(a[k]) | set(b[k]) if b[k].get(c) != a[k].get(c)}
85
+ if cols:
86
+ changed[k] = cols
87
+ if added or removed or changed:
88
+ result[table] = {"added": added, "removed": removed, "changed": changed}
89
+ return result
90
+
91
+
92
+ def query(config: HarnessConfig, sql: str) -> Any:
93
+ """One value (or rows) from the read-only replica, for state checks."""
94
+ con = sqlite3.connect(f"file:{config.sor_path.as_posix()}?mode=ro", uri=True)
95
+ try:
96
+ rows = con.execute(sql).fetchall()
97
+ finally:
98
+ con.close()
99
+ if len(rows) == 1 and len(rows[0]) == 1:
100
+ return rows[0][0]
101
+ return [list(r) for r in rows]
@@ -0,0 +1,49 @@
1
+ """Test catalog: suites of ordered cases, each a turn in one conversation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ import yaml
10
+
11
+
12
+ @dataclass
13
+ class Suite:
14
+ name: str
15
+ user: str
16
+ customer_id: str
17
+ starting_state: str
18
+ cases: list[dict[str, Any]]
19
+
20
+
21
+ def load_catalog(path: Path) -> list[Suite]:
22
+ data = yaml.safe_load(Path(path).read_text(encoding="utf-8"))
23
+ return [Suite(s["suite"], s["user"], s["customer_id"], s.get("starting_state", ""), s["cases"]) for s in data["suites"]]
24
+
25
+
26
+ def select(suites: list[Suite], suite_names: list[str] | None = None, case_id: str | None = None) -> list[Suite]:
27
+ """Choose suites; with case_id, run that case's suite up to and including the case (earlier turns set it up)."""
28
+ if case_id:
29
+ for s in suites:
30
+ ids = [c["id"] for c in s.cases]
31
+ if case_id in ids:
32
+ return [Suite(s.name, s.user, s.customer_id, s.starting_state, s.cases[: ids.index(case_id) + 1])]
33
+ raise KeyError(f"Case {case_id} not found in the catalog")
34
+ if suite_names:
35
+ wanted = {n.upper() for n in suite_names}
36
+ chosen = [s for s in suites if s.name.upper() in wanted]
37
+ missing = wanted - {s.name.upper() for s in chosen}
38
+ if missing:
39
+ raise KeyError(f"Unknown suite(s): {', '.join(sorted(missing))}")
40
+ return chosen
41
+ return suites
42
+
43
+
44
+ def find_case(suites: list[Suite], case_id: str) -> tuple[Suite, dict[str, Any]]:
45
+ for s in suites:
46
+ for c in s.cases:
47
+ if c["id"] == case_id:
48
+ return s, c
49
+ raise KeyError(case_id)