diteko 0.1.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.
diteko/__init__.py ADDED
@@ -0,0 +1,12 @@
1
+ """Diteko: generate, export and run tests for Amazon Connect contact flows.
2
+
3
+ The testing counterpart to cxblueprint: build or export a flow, then let Diteko derive
4
+ its grammar, evolve a test suite, export it (Excel test script, JSON, Connect native)
5
+ and run it on Connect's native testing.
6
+ """
7
+
8
+ from ._http import DitekoError
9
+ from .sdk import FOCUS, SIZES, Diteko, Execution, Grammar, Project, Projects, Run, Suite, Test
10
+
11
+ __all__ = ["Diteko", "DitekoError", "Execution", "FOCUS", "Grammar", "Project", "Projects", "Run", "SIZES", "Suite", "Test"]
12
+ __version__ = "0.1.0"
diteko/_http.py ADDED
@@ -0,0 +1,59 @@
1
+ """The HTTP layer: one place that knows the API's paths and turns errors into DitekoError."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ import httpx
8
+
9
+
10
+ class DitekoError(RuntimeError):
11
+ """The Diteko API refused a request, or could not be reached."""
12
+
13
+ def __init__(self, message: str, status: int | None = None, body: Any = None):
14
+ super().__init__(message)
15
+ self.status = status
16
+ self.body = body
17
+
18
+
19
+ class Http:
20
+ """A thin client for the Diteko REST API (``/api/**``)."""
21
+
22
+ def __init__(self, base_url: str, *, timeout: float = 60.0, transport: httpx.BaseTransport | None = None):
23
+ self._client = httpx.Client(base_url=base_url.rstrip("/") + "/api", timeout=timeout, transport=transport)
24
+
25
+ def close(self) -> None:
26
+ self._client.close()
27
+
28
+ def request(self, method: str, path: str, *, json: Any = None, params: dict[str, Any] | None = None) -> httpx.Response:
29
+ clean = {k: v for k, v in (params or {}).items() if v is not None}
30
+ try:
31
+ response = self._client.request(method, path, json=json, params=clean)
32
+ except httpx.HTTPError as e:
33
+ raise DitekoError(f"Could not reach the Diteko API at {self._client.base_url}: {e}") from e
34
+ if response.status_code >= 400:
35
+ try:
36
+ body: Any = response.json()
37
+ except ValueError:
38
+ body = response.text
39
+ detail = body.get("detail") or body.get("title") if isinstance(body, dict) else body
40
+ raise DitekoError(f"{method} {path} failed with HTTP {response.status_code}: {detail}", response.status_code, body)
41
+ return response
42
+
43
+ def get(self, path: str, **params: Any) -> Any:
44
+ return self.request("GET", path, params=params).json()
45
+
46
+ def post(self, path: str, body: Any = None, **params: Any) -> Any:
47
+ response = self.request("POST", path, json=body, params=params)
48
+ return response.json() if response.content else None
49
+
50
+ def put(self, path: str, body: Any) -> Any:
51
+ return self.request("PUT", path, json=body).json()
52
+
53
+ def download(self, path: str, **params: Any) -> tuple[bytes, str | None]:
54
+ response = self.request("GET", path, params=params)
55
+ disposition = response.headers.get("content-disposition", "")
56
+ name = None
57
+ if "filename=" in disposition:
58
+ name = disposition.split("filename=", 1)[1].strip('"; ')
59
+ return response.content, name
diteko/mcp_server.py ADDED
@@ -0,0 +1,122 @@
1
+ """MCP server: drive Diteko from Claude Desktop, Cursor or VS Code.
2
+
3
+ pip install "diteko[mcp]"
4
+ DITEKO_URL=http://localhost:8888 diteko-mcp
5
+
6
+ Publishing to Connect defaults to a dry run; a live run must be asked for explicitly,
7
+ because it bills the AWS account behind the deployment.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import os
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ from .sdk import FOCUS, SIZES, Diteko
18
+
19
+ try: # mcp 2.x renamed FastMCP to MCPServer; the API this module uses is otherwise the same
20
+ from mcp.server.mcpserver import MCPServer as _Server
21
+ except ImportError:
22
+ try:
23
+ from mcp.server.fastmcp import FastMCP as _Server
24
+ except ImportError as e: # pragma: no cover - exercised only without the extra
25
+ raise SystemExit('The MCP server needs the "mcp" extra: pip install "diteko[mcp]"') from e
26
+ from mcp.types import ToolAnnotations
27
+
28
+ mcp = _Server("diteko")
29
+
30
+
31
+ def _hints(**hints: bool) -> ToolAnnotations:
32
+ """Tool annotations by their wire names, which both mcp 1.x and 2.x accept."""
33
+ return ToolAnnotations.model_validate(hints)
34
+
35
+
36
+ READ_ONLY = _hints(readOnlyHint=True, openWorldHint=False)
37
+ CHANGES_DITEKO = _hints(readOnlyHint=False, destructiveHint=False, openWorldHint=False)
38
+ _client: Diteko | None = None
39
+
40
+
41
+ def _dk() -> Diteko:
42
+ global _client
43
+ if _client is None:
44
+ _client = Diteko(os.environ.get("DITEKO_URL", "http://localhost:8888"))
45
+ return _client
46
+
47
+
48
+ def _project(p: Any) -> dict[str, Any]:
49
+ return {"flow_id": p.flow_id, "name": p.name, "next_step": p.next_step, "connect_flow_id": p.connect_flow_id, "suite_run_id": p.summary.get("suiteRunId")}
50
+
51
+
52
+ @mcp.tool(annotations=READ_ONLY)
53
+ def list_projects() -> list[dict[str, Any]]:
54
+ """List every contact flow under test, with the next step for each (GRAMMAR, EVOLVE, RUN or DONE)."""
55
+ return [_project(p) for p in _dk().projects.list()]
56
+
57
+
58
+ @mcp.tool(annotations=CHANGES_DITEKO)
59
+ def import_flow(name: str, flow_json: str | None = None, path: str | None = None, connect_flow_id: str | None = None) -> dict[str, Any]:
60
+ """Register a contact flow as a project: from flow JSON text (Connect export or cxblueprint output), a .json file path, or a flow id in the configured Connect instance."""
61
+ projects = _dk().projects
62
+ if connect_flow_id:
63
+ return _project(projects.import_from_connect(connect_flow_id))
64
+ if path:
65
+ return _project(projects.from_json(Path(path), name=name))
66
+ if flow_json:
67
+ return _project(projects.from_json(flow_json, name=name))
68
+ raise ValueError("Give flow_json, path or connect_flow_id")
69
+
70
+
71
+ @mcp.tool(annotations=CHANGES_DITEKO)
72
+ def generate_grammar(flow_id: int) -> dict[str, Any]:
73
+ """Generate, store and validate the grammar for a project's flow. Returns it as BNF."""
74
+ g = _dk().projects.get(flow_id).grammar.generate()
75
+ return {"grammar_id": g.id, "start_symbol": g.start_symbol, "bnf": g.bnf}
76
+
77
+
78
+ @mcp.tool(annotations=READ_ONLY)
79
+ def convert_grammar(content: str, source: str, target: str) -> str:
80
+ """Convert a grammar between "bnf" and "json" (exact reflections of each other)."""
81
+ return _dk().convert_grammar(content, source.lower(), target.lower()) # type: ignore[arg-type]
82
+
83
+
84
+ @mcp.tool(annotations=CHANGES_DITEKO)
85
+ def start_evolution(flow_id: int, size: str = "standard", focus: str = "balanced", seed: int | None = None) -> dict[str, Any]:
86
+ """Start an evolution run for a project. size: quick, standard or thorough; focus: balanced, coverage, errors or transfers. Returns immediately; poll get_run."""
87
+ if size not in SIZES or focus not in FOCUS:
88
+ raise ValueError(f"size must be one of {sorted(SIZES)} and focus one of {sorted(FOCUS)}")
89
+ run = _dk().projects.get(flow_id).evolve(size, focus, seed=seed) # type: ignore[arg-type]
90
+ return {"run_id": run.id, "status": run.status}
91
+
92
+
93
+ @mcp.tool(annotations=READ_ONLY)
94
+ def get_run(run_id: int) -> dict[str, Any]:
95
+ """An evolution run's progress: status, generation, best/average fitness."""
96
+ return _dk().run(run_id).progress
97
+
98
+
99
+ @mcp.tool(annotations=_hints(readOnlyHint=False, destructiveHint=True, idempotentHint=True, openWorldHint=False)) # writes a local file
100
+ def export_suite(run_id: int, format: str = "xlsx", path: str | None = None, top: int | None = None, selection: str = "fitness") -> str:
101
+ """Export a run's suite. format: xlsx (manual test script), json, or native (Connect test cases). selection: fitness or coverage. Returns the file written."""
102
+ suite = _dk().run(run_id).suite(top, selection) # type: ignore[arg-type]
103
+ exporters = {"xlsx": suite.export_xlsx, "json": suite.export_json, "native": suite.export_native}
104
+ if format not in exporters:
105
+ raise ValueError(f"format must be one of {sorted(exporters)}")
106
+ return str(exporters[format](path).resolve())
107
+
108
+
109
+ # Reaches Amazon Connect; a live run (dry_run=false) bills the AWS account.
110
+ @mcp.tool(annotations=_hints(readOnlyHint=False, destructiveHint=False, openWorldHint=True))
111
+ def publish_suite(run_id: int, dry_run: bool = True, top: int | None = None, selection: str = "fitness") -> dict[str, Any]:
112
+ """Run a suite on Amazon Connect native testing. Dry run by default: nothing is called or billed. A live run (dry_run=false) bills one execution per test."""
113
+ execution = _dk().run(run_id).suite(top, selection).publish_to_connect(dry_run=dry_run) # type: ignore[arg-type]
114
+ return {"upload_id": execution.upload_id, "dry_run": execution.dry_run, "passed": execution.passed, "failed": execution.failed, "verdicts": execution.verdicts}
115
+
116
+
117
+ def main() -> None:
118
+ mcp.run()
119
+
120
+
121
+ if __name__ == "__main__":
122
+ main()
diteko/sdk.py ADDED
@@ -0,0 +1,459 @@
1
+ """The fluent API: projects, grammars, runs, suites and executions.
2
+
3
+ A project is a contact flow under test. The pipeline is the same as in the console:
4
+ flow -> grammar -> evolve (a run) -> suite -> run on Connect -> results.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json as _json
10
+ import time
11
+ from dataclasses import dataclass, field
12
+ from pathlib import Path
13
+ from typing import Any, Iterable, Literal
14
+
15
+ from ._http import DitekoError, Http
16
+
17
+ Size = Literal["quick", "standard", "thorough"]
18
+ Focus = Literal["balanced", "coverage", "errors", "transfers"]
19
+ Selection = Literal["fitness", "coverage"]
20
+ GrammarFormat = Literal["bnf", "json"]
21
+
22
+ #: Run sizes, the same as the console's: calls per generation x generations.
23
+ SIZES: dict[str, dict[str, int]] = {
24
+ "quick": {"populationSize": 40, "generations": 15},
25
+ "standard": {"populationSize": 100, "generations": 50},
26
+ "thorough": {"populationSize": 200, "generations": 120},
27
+ }
28
+
29
+ #: What a suite should be good at, as fitness weights (the console's "Focus").
30
+ FOCUS: dict[str, dict[str, float]] = {
31
+ "balanced": {"state_coverage": 0.3, "branch_coverage": 0.3, "happy_path": 0.2, "error_recovery": 0.2},
32
+ "coverage": {"state_coverage": 0.5, "branch_coverage": 0.5},
33
+ "errors": {"error_recovery": 0.6, "branch_coverage": 0.4},
34
+ "transfers": {"queue_reachability": 0.6, "state_coverage": 0.4},
35
+ }
36
+
37
+ _SELECTION = {"fitness": "FITNESS", "coverage": "ADDITIONAL_COVERAGE"}
38
+ _RUNNING = {"INITIALIZING", "RUNNING", "PAUSED"}
39
+
40
+
41
+ def _selection(value: Selection) -> str:
42
+ try:
43
+ return _SELECTION[value]
44
+ except KeyError:
45
+ raise ValueError(f"selection must be one of {sorted(_SELECTION)}, not {value!r}") from None
46
+
47
+
48
+ def _flow_json(flow: str | Path | dict[str, Any]) -> str:
49
+ """A flow definition as a JSON string, from a dict, a JSON string or a path to a .json file."""
50
+ if isinstance(flow, dict):
51
+ return _json.dumps(flow)
52
+ if isinstance(flow, Path) or (isinstance(flow, str) and not flow.lstrip().startswith("{")):
53
+ return Path(flow).read_text(encoding="utf-8")
54
+ _json.loads(flow) # fail early, with Python's message, on text that is not JSON
55
+ return flow
56
+
57
+
58
+ # --------------------------------------------------------------------------- grammar
59
+
60
+
61
+ @dataclass
62
+ class Grammar:
63
+ """A flow's grammar: which calls count as tests. Editable as BNF or JSON (exact reflections)."""
64
+
65
+ _http: Http = field(repr=False)
66
+ flow_id: int
67
+ bnf: str
68
+ start_symbol: str = "test"
69
+ id: int | None = None
70
+ name: str | None = None
71
+
72
+ def to_bnf(self) -> str:
73
+ return self.bnf
74
+
75
+ def to_json(self) -> dict[str, Any]:
76
+ """The grammar in Diteko's JSON form (``diteko-grammar/v1``)."""
77
+ result = self._http.post("/grammars/convert", {"content": self.bnf, "from": "BNF", "to": "JSON", "startSymbol": self.start_symbol})
78
+ if not result.get("converted"):
79
+ raise DitekoError("The grammar cannot be converted: " + "; ".join(result.get("errors", [])))
80
+ return _json.loads(result["content"])
81
+
82
+ def replace(self, content: str | dict[str, Any], format: GrammarFormat = "bnf") -> "Grammar":
83
+ """Replace the grammar's text, given as BNF or as JSON (a dict or a string)."""
84
+ if format == "json":
85
+ text = content if isinstance(content, str) else _json.dumps(content)
86
+ result = self._http.post("/grammars/convert", {"content": text, "from": "JSON", "to": "BNF"})
87
+ if not result.get("converted"):
88
+ raise DitekoError("The JSON grammar cannot be read: " + "; ".join(result.get("errors", [])))
89
+ self.bnf = result["content"]
90
+ self.start_symbol = result.get("startSymbol") or self.start_symbol
91
+ else:
92
+ self.bnf = str(content)
93
+ return self
94
+
95
+ def save(self) -> "Grammar":
96
+ """Store the grammar on the flow (creating it if new) and validate it; raises if it is invalid."""
97
+ body = {"name": self.name or f"Grammar for flow {self.flow_id}", "bnfDefinition": self.bnf, "startSymbol": self.start_symbol, "contactFlow": {"id": self.flow_id}}
98
+ if self.id is None:
99
+ saved = self._http.post("/grammars", body)
100
+ else:
101
+ saved = self._http.put(f"/grammars/{self.id}", {**body, "id": self.id})
102
+ self.id, self.name = saved["id"], saved.get("name")
103
+ result = self._http.post(f"/grammars/{self.id}/validate")
104
+ if not result.get("valid"):
105
+ raise DitekoError("The grammar is invalid: " + "; ".join(result.get("errors", [])), body=result)
106
+ return self
107
+
108
+
109
+ class GrammarAccess:
110
+ """``project.grammar``: the flow's latest grammar, or a new one generated from the flow."""
111
+
112
+ def __init__(self, project: "Project"):
113
+ self._project = project
114
+
115
+ def generate(self, save: bool = True) -> Grammar:
116
+ """Generate a grammar from the flow (and, by default, store and validate it)."""
117
+ p = self._project
118
+ generated = p._http.post(f"/contact-flows/{p.flow_id}/generate-grammar")
119
+ grammar = Grammar(p._http, p.flow_id, generated["bnfDefinition"], generated.get("startSymbol") or "test", name=f"{p.name} grammar")
120
+ return grammar.save() if save else grammar
121
+
122
+ def latest(self) -> Grammar | None:
123
+ """The grammar new runs use, or None if the flow has none yet."""
124
+ p = self._project.refresh()
125
+ if not p.summary.get("grammar"):
126
+ return None
127
+ g = p._http.get(f"/grammars/{p.summary['grammar']['id']}")
128
+ return Grammar(p._http, p.flow_id, g["bnfDefinition"], g.get("startSymbol") or "test", id=g["id"], name=g.get("name"))
129
+
130
+
131
+ # --------------------------------------------------------------------------- suite and execution
132
+
133
+
134
+ @dataclass
135
+ class Test:
136
+ """One generated test: a call through the flow."""
137
+
138
+ id: int
139
+ test_id: str
140
+ steps: list[str]
141
+ fitness: float | None
142
+ priority: int | None
143
+ scores: dict[str, float]
144
+
145
+ @property
146
+ def keys(self) -> list[str]:
147
+ """The keys the caller presses, in order (TIMEOUT = silence, INVALID = an unoffered key)."""
148
+ return [s[7:] for s in self.steps if s.startswith("PRESS: ")]
149
+
150
+
151
+ @dataclass
152
+ class Execution:
153
+ """One publish to Connect's native testing: its verdicts per test."""
154
+
155
+ _http: Http = field(repr=False)
156
+ upload_id: str
157
+ raw: dict[str, Any]
158
+
159
+ @property
160
+ def status(self) -> str:
161
+ return self.raw.get("status", "")
162
+
163
+ @property
164
+ def dry_run(self) -> bool:
165
+ return bool(self.raw.get("dryRun"))
166
+
167
+ @property
168
+ def verdicts(self) -> dict[str, str]:
169
+ """Test id -> outcome (PASSED, FAILED, ERRORED for "Connect could not start it", ...)."""
170
+ return {e.get("testId"): e.get("status") for e in self.raw.get("executions", [])}
171
+
172
+ @property
173
+ def passed(self) -> int:
174
+ return sum(1 for v in self.verdicts.values() if v == "PASSED")
175
+
176
+ @property
177
+ def failed(self) -> int:
178
+ return sum(1 for v in self.verdicts.values() if v == "FAILED")
179
+
180
+ def refresh(self) -> "Execution":
181
+ self.raw = self._http.get(f"/test-cases/connect-runs/{self.upload_id}")
182
+ return self
183
+
184
+ def wait(self, timeout: float = 1800, poll: float = 3) -> "Execution":
185
+ """Block until the run completes (or raise TimeoutError)."""
186
+ deadline = time.monotonic() + timeout
187
+ while self.refresh().status != "COMPLETED":
188
+ if time.monotonic() > deadline:
189
+ raise TimeoutError(f"Connect run {self.upload_id} still {self.status} after {timeout}s")
190
+ time.sleep(poll)
191
+ return self
192
+
193
+
194
+ @dataclass
195
+ class Suite:
196
+ """The tests a completed run kept, in the order and size chosen."""
197
+
198
+ _http: Http = field(repr=False)
199
+ run_id: int
200
+ top: int | None = None
201
+ selection: Selection = "fitness"
202
+ _tests: list[Test] | None = field(default=None, repr=False)
203
+
204
+ @property
205
+ def tests(self) -> list[Test]:
206
+ if self._tests is None:
207
+ rows = self._http.post("/test-cases/prioritize", {"evolutionRunId": self.run_id, "topN": self.top, "selection": _selection(self.selection)})
208
+ self._tests = [
209
+ Test(r["id"], r["testId"], r.get("actions", []), r.get("compositeFitness"), r.get("priority"), r.get("fitnessScores") or {}) for r in rows
210
+ ]
211
+ return self._tests
212
+
213
+ def __len__(self) -> int:
214
+ return len(self.tests)
215
+
216
+ def __iter__(self) -> Iterable[Test]: # type: ignore[override]
217
+ return iter(self.tests)
218
+
219
+ def export_xlsx(self, path: str | Path | None = None) -> Path:
220
+ """Download the Excel test script (ISO/IEC/IEEE 29119-3 layout) for manual execution."""
221
+ content, name = self._http.download("/test-cases/export.xlsx", evolutionRunId=self.run_id, topN=self.top, selection=_selection(self.selection))
222
+ target = Path(path) if path else Path(name or f"diteko-run-{self.run_id}-test-script.xlsx")
223
+ target.write_bytes(content)
224
+ return target
225
+
226
+ def _export(self, format: str, path: str | Path | None) -> Path:
227
+ result = self._http.post("/test-cases/export", {"evolutionRunId": self.run_id, "format": format, "topN": self.top, "selection": _selection(self.selection)})
228
+ target = Path(path) if path else Path(result.get("fileName") or f"diteko-run-{self.run_id}-{format.lower()}.json")
229
+ target.write_text(_json.dumps(result, indent=2), encoding="utf-8")
230
+ return target
231
+
232
+ def export_json(self, path: str | Path | None = None) -> Path:
233
+ """Portable JSON: every test with its steps and scores."""
234
+ return self._export("JSON", path)
235
+
236
+ def export_native(self, path: str | Path | None = None) -> Path:
237
+ """Amazon Connect native test cases (only tests Connect can run meaningfully)."""
238
+ return self._export("NATIVE", path)
239
+
240
+ def publish_to_connect(self, dry_run: bool = True, wait: bool = True, connect_flow_id: str | None = None) -> Execution:
241
+ """Publish and execute on Connect native testing. Dry run by default: nothing is called or billed."""
242
+ result = self._http.post(
243
+ "/test-cases/upload-to-connect",
244
+ {"evolutionRunId": self.run_id, "topN": self.top, "selection": _selection(self.selection), "dryRun": dry_run, "connectFlowId": connect_flow_id},
245
+ )
246
+ execution = Execution(self._http, result["uploadId"], result)
247
+ return execution.wait() if wait else execution
248
+
249
+
250
+ # --------------------------------------------------------------------------- run
251
+
252
+
253
+ @dataclass
254
+ class Run:
255
+ """An evolution run: generations of test calls bred against the grammar."""
256
+
257
+ _http: Http = field(repr=False)
258
+ id: int
259
+ name: str | None = None
260
+ progress: dict[str, Any] = field(default_factory=dict)
261
+
262
+ @property
263
+ def status(self) -> str:
264
+ return self.progress.get("status", "INITIALIZING")
265
+
266
+ def refresh(self) -> "Run":
267
+ self.progress = self._http.get(f"/evolution-runs/{self.id}/progress")
268
+ return self
269
+
270
+ def wait(self, timeout: float = 3600, poll: float = 2) -> "Run":
271
+ """Block until the run finishes; raises DitekoError if it failed, TimeoutError if it is slow."""
272
+ deadline = time.monotonic() + timeout
273
+ while self.refresh().status in _RUNNING:
274
+ if time.monotonic() > deadline:
275
+ raise TimeoutError(f"Run {self.id} still {self.status} after {timeout}s")
276
+ time.sleep(poll)
277
+ if self.status != "COMPLETED":
278
+ raise DitekoError(f"Run {self.id} ended {self.status}", body=self.progress)
279
+ return self
280
+
281
+ def suite(self, top: int | None = None, selection: Selection = "fitness") -> Suite:
282
+ """The run's tests: all, or the top N by fitness or by added coverage."""
283
+ _selection(selection)
284
+ return Suite(self._http, self.id, top, selection)
285
+
286
+ def pause(self) -> "Run":
287
+ self._http.post(f"/evolution-runs/{self.id}/pause")
288
+ return self.refresh()
289
+
290
+ def resume(self) -> "Run":
291
+ self._http.post(f"/evolution-runs/{self.id}/resume")
292
+ return self.refresh()
293
+
294
+ def cancel(self) -> "Run":
295
+ self._http.post(f"/evolution-runs/{self.id}/cancel")
296
+ return self.refresh()
297
+
298
+
299
+ # --------------------------------------------------------------------------- project
300
+
301
+
302
+ class Project:
303
+ """A contact flow under test, and where it stands in the pipeline."""
304
+
305
+ def __init__(self, http: Http, summary: dict[str, Any]):
306
+ self._http = http
307
+ self.summary = summary
308
+ self.grammar = GrammarAccess(self)
309
+
310
+ def __repr__(self) -> str:
311
+ return f"Project(flow_id={self.flow_id}, name={self.name!r}, next_step={self.next_step!r})"
312
+
313
+ @property
314
+ def flow_id(self) -> int:
315
+ return int(self.summary["flowId"])
316
+
317
+ @property
318
+ def name(self) -> str:
319
+ return self.summary.get("name", "")
320
+
321
+ @property
322
+ def next_step(self) -> str:
323
+ """GRAMMAR, EVOLVE, RUN or DONE."""
324
+ return self.summary.get("nextStep", "")
325
+
326
+ @property
327
+ def connect_flow_id(self) -> str | None:
328
+ return self.summary.get("awsFlowId")
329
+
330
+ def refresh(self) -> "Project":
331
+ self.summary = self._http.get(f"/projects/{self.flow_id}")
332
+ return self
333
+
334
+ def validate(self) -> dict[str, Any]:
335
+ """Validate the flow definition: ``{"valid": bool, "errors": [...], "warnings": [...]}``."""
336
+ return self._http.post(f"/contact-flows/{self.flow_id}/validate")
337
+
338
+ def evolve(
339
+ self,
340
+ preset: Size = "standard",
341
+ focus: Focus = "balanced",
342
+ *,
343
+ name: str | None = None,
344
+ seed: int | None = None,
345
+ population: int | None = None,
346
+ generations: int | None = None,
347
+ weights: dict[str, float] | None = None,
348
+ ) -> Run:
349
+ """Start an evolution run (returns immediately; call ``.wait()``). Uses the latest grammar, or generates one."""
350
+ if preset not in SIZES:
351
+ raise ValueError(f"preset must be one of {sorted(SIZES)}")
352
+ if weights is None and focus not in FOCUS:
353
+ raise ValueError(f"focus must be one of {sorted(FOCUS)}")
354
+ config = {**SIZES[preset], "mutationRate": 0.1, "crossoverRate": 0.8, "tournamentSize": 3, "elitismCount": 2, "randomSeed": seed}
355
+ if population:
356
+ config["populationSize"] = population
357
+ if generations:
358
+ config["generations"] = generations
359
+ chosen = weights or FOCUS[focus]
360
+ summary = self._http.post(
361
+ "/evolution-runs/launch",
362
+ {
363
+ "contactFlowId": self.flow_id,
364
+ "grammarId": (self.refresh().summary.get("grammar") or {}).get("id"),
365
+ "name": name or f"{preset.capitalize()} run, {focus} focus",
366
+ "optimizationStrategy": "WEIGHTED_SUM",
367
+ "config": config,
368
+ "fitnessWeights": [{"fitnessFunctionId": k, "weight": v, "enabled": True, "priority": i} for i, (k, v) in enumerate(chosen.items())],
369
+ },
370
+ )
371
+ return Run(self._http, summary["id"], summary.get("name")).refresh()
372
+
373
+ def runs(self) -> list[Run]:
374
+ """The flow's evolution runs, newest first."""
375
+ rows = self._http.get("/evolution-runs", **{"contactFlowId.equals": self.flow_id, "sort": "id,desc", "size": 200})
376
+ return [Run(self._http, r["id"], r.get("name"), {"status": r.get("status")}) for r in rows]
377
+
378
+ def suite(self, top: int | None = None, selection: Selection = "fitness") -> Suite:
379
+ """The project's current suite (its latest completed run that produced tests)."""
380
+ run_id = self.refresh().summary.get("suiteRunId")
381
+ if run_id is None:
382
+ raise DitekoError(f"{self.name} has no suite yet; run project.evolve().wait() first")
383
+ return Run(self._http, run_id).suite(top, selection)
384
+
385
+
386
+ class Projects:
387
+ """``dk.projects``: list, open, and create projects from flow JSON, cxblueprint or Connect."""
388
+
389
+ def __init__(self, http: Http):
390
+ self._http = http
391
+
392
+ def list(self) -> list[Project]:
393
+ return [Project(self._http, p) for p in self._http.get("/projects")]
394
+
395
+ def get(self, flow_id: int) -> Project:
396
+ return Project(self._http, self._http.get(f"/projects/{flow_id}"))
397
+
398
+ def find(self, name: str) -> Project | None:
399
+ return next((p for p in self.list() if p.name == name), None)
400
+
401
+ def from_json(self, flow: str | Path | dict[str, Any], name: str | None = None, description: str | None = None) -> Project:
402
+ """Register a flow from Connect-exported or cxblueprint-compiled JSON (a dict, a JSON string, or a file path)."""
403
+ definition = _flow_json(flow)
404
+ if name is None:
405
+ name = Path(flow).stem if isinstance(flow, (str, Path)) and not str(flow).lstrip().startswith("{") else None
406
+ if not name:
407
+ raise ValueError("name is required when the flow is not given as a file path")
408
+ created = self._http.post("/contact-flows", {"name": name, "description": description, "definition": definition})
409
+ return self.get(created["id"])
410
+
411
+ def from_cxblueprint(self, flow: Any, name: str | None = None) -> Project:
412
+ """Register a flow built with cxblueprint: compiles it (``flow.compile()``) and uploads the JSON."""
413
+ compiled = flow.compile()
414
+ return self.from_json(compiled, name=name or getattr(flow, "name", None) or compiled.get("Metadata", {}).get("name"))
415
+
416
+ def import_from_connect(self, connect_flow_id: str) -> Project:
417
+ """Import (or refresh) a flow straight from the configured Connect instance."""
418
+ imported = self._http.post("/contact-flows/import-from-connect", {"connectFlowId": connect_flow_id})
419
+ return self.get(imported["id"])
420
+
421
+
422
+ class Diteko:
423
+ """Entry point::
424
+
425
+ dk = Diteko("http://localhost:8888")
426
+ project = dk.projects.from_json("claims.json")
427
+ project.grammar.generate()
428
+ run = project.evolve(preset="standard").wait()
429
+ suite = run.suite(top=30, selection="coverage")
430
+ suite.export_xlsx("claims-tests.xlsx")
431
+ suite.publish_to_connect(dry_run=True)
432
+ """
433
+
434
+ def __init__(self, url: str = "http://localhost:8888", *, timeout: float = 60.0, transport: Any = None):
435
+ self._http = Http(url, timeout=timeout, transport=transport)
436
+ self.projects = Projects(self._http)
437
+
438
+ def connect_settings(self) -> dict[str, Any]:
439
+ """How the deployment reaches Amazon Connect (read-only)."""
440
+ return self._http.get("/settings/connect")
441
+
442
+ def convert_grammar(self, content: str, source: GrammarFormat, target: GrammarFormat, start_symbol: str | None = None) -> str:
443
+ """Convert a grammar between BNF and JSON without storing it."""
444
+ result = self._http.post("/grammars/convert", {"content": content, "from": source.upper(), "to": target.upper(), "startSymbol": start_symbol})
445
+ if not result.get("converted"):
446
+ raise DitekoError("The grammar cannot be converted: " + "; ".join(result.get("errors", [])), body=result)
447
+ return result["content"]
448
+
449
+ def run(self, run_id: int) -> Run:
450
+ return Run(self._http, run_id).refresh()
451
+
452
+ def close(self) -> None:
453
+ self._http.close()
454
+
455
+ def __enter__(self) -> "Diteko":
456
+ return self
457
+
458
+ def __exit__(self, *exc: Any) -> None:
459
+ self.close()
@@ -0,0 +1,147 @@
1
+ Metadata-Version: 2.5
2
+ Name: diteko
3
+ Version: 0.1.0
4
+ Summary: Generate, export and run tests for Amazon Connect contact flows with Diteko (Grammatical Evolution).
5
+ Author: Lehlohonolo Sehako
6
+ License-Expression: MIT
7
+ License-File: LICENSE
8
+ Keywords: amazon-connect,contact-flow,cxblueprint,grammatical-evolution,ivr,mcp,testing
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Topic :: Communications :: Telephony
17
+ Classifier: Topic :: Software Development :: Testing
18
+ Requires-Python: >=3.10
19
+ Requires-Dist: httpx>=0.27
20
+ Provides-Extra: cxblueprint
21
+ Requires-Dist: cxblueprint>=0.1.0.post15; extra == 'cxblueprint'
22
+ Provides-Extra: dev
23
+ Requires-Dist: pytest>=8; extra == 'dev'
24
+ Provides-Extra: mcp
25
+ Requires-Dist: mcp<3,>=1.26.0; extra == 'mcp'
26
+ Description-Content-Type: text/markdown
27
+
28
+ # diteko (Python)
29
+
30
+ Generate, export and run tests for Amazon Connect contact flows, from Python. It is the
31
+ testing counterpart to [cxblueprint](https://pypi.org/project/cxblueprint/): build or
32
+ export a flow, then let Diteko derive its grammar, evolve a test suite with Grammatical
33
+ Evolution, export it, and run it on Connect's native testing.
34
+
35
+ The SDK is a client: it talks to a running Diteko deployment over its REST API, so you need
36
+ one to point it at. Diteko has no application-level authentication, so point the SDK only
37
+ at a deployment inside your network or behind your VPN.
38
+
39
+ ```bash
40
+ pip install diteko # SDK
41
+ pip install "diteko[mcp]" # + MCP server for Claude Desktop / Cursor / VS Code
42
+ pip install "diteko[cxblueprint]"
43
+ ```
44
+
45
+ ## The pipeline
46
+
47
+ ```python
48
+ from diteko import Diteko
49
+
50
+ dk = Diteko("http://localhost:8888")
51
+
52
+ project = dk.projects.from_json("claims.json") # Connect export or cxblueprint output
53
+ project.grammar.generate() # derive, store and validate the grammar
54
+ run = project.evolve(preset="standard", focus="balanced").wait()
55
+ suite = run.suite(top=30, selection="coverage") # or selection="fitness"
56
+
57
+ for test in suite:
58
+ print(test.test_id, test.keys, test.fitness)
59
+
60
+ suite.export_xlsx("claims-tests.xlsx") # manual test script (ISO/IEC/IEEE 29119-3)
61
+ suite.export_native("claims-native.json") # Amazon Connect native test cases
62
+ result = suite.publish_to_connect(dry_run=True) # dry run unless you say otherwise
63
+ print(result.passed, result.failed, result.verdicts)
64
+ ```
65
+
66
+ ### Straight from cxblueprint
67
+
68
+ ```python
69
+ from cxblueprint import Flow
70
+ from diteko import Diteko
71
+
72
+ flow = Flow.build("Burger Order")
73
+ menu = flow.get_input("Press 1 for Classic or 2 for Veggie", timeout=10)
74
+ # ... build the flow as usual ...
75
+
76
+ project = Diteko().projects.from_cxblueprint(flow) # compiles locally, uploads the JSON
77
+ ```
78
+
79
+ ### Other ways in
80
+
81
+ ```python
82
+ dk.projects.import_from_connect("08e145dc-d959-4094-9021-6f10d8590be2") # from the instance
83
+ dk.projects.list() # every project and its next step
84
+ dk.projects.get(21601).suite(top=10).export_xlsx()
85
+ ```
86
+
87
+ ### Grammars as BNF or JSON
88
+
89
+ The two forms are exact reflections of each other. Both are validated by the same parser,
90
+ and both keep comments.
91
+
92
+ ```python
93
+ g = project.grammar.latest()
94
+ spec = g.to_json() # {"format": "diteko-grammar/v1", "start": ..., "rules": [...]}
95
+ g.replace(spec, format="json").save() # edit as JSON, store as canonical BNF
96
+ dk.convert_grammar(bnf_text, "bnf", "json")
97
+ ```
98
+
99
+ ### Presets
100
+
101
+ | `preset` | calls × generations |
102
+ | ---------- | ------------------- |
103
+ | `quick` | 40 × 15 |
104
+ | `standard` | 100 × 50 |
105
+ | `thorough` | 200 × 120 |
106
+
107
+ | `focus` | favours |
108
+ | ----------- | ---------------------------------------------------- |
109
+ | `balanced` | blocks and branches, plus happy paths and recoveries |
110
+ | `coverage` | visiting as much of the flow as possible |
111
+ | `errors` | timeouts and wrong keys that still end properly |
112
+ | `transfers` | calls that reach an agent queue |
113
+
114
+ Pass `population=`, `generations=`, `weights={"state_coverage": 1.0}` or `seed=` to go beyond them.
115
+
116
+ ## MCP server
117
+
118
+ ```bash
119
+ DITEKO_URL=http://localhost:8888 diteko-mcp
120
+ ```
121
+
122
+ ```json
123
+ { "mcpServers": { "diteko": { "command": "diteko-mcp", "env": { "DITEKO_URL": "http://localhost:8888" } } } }
124
+ ```
125
+
126
+ | Tool | Does |
127
+ | ------------------ | -------------------------------------------------------------------------------------- |
128
+ | `list_projects` | every flow under test and its next step |
129
+ | `import_flow` | register a flow from JSON text, a file path, or a Connect flow id |
130
+ | `generate_grammar` | derive, store and validate a flow's grammar |
131
+ | `convert_grammar` | BNF ⇄ JSON |
132
+ | `start_evolution` | start a run (`size`, `focus`, `seed`) |
133
+ | `get_run` | a run's progress |
134
+ | `export_suite` | `xlsx`, `json` or `native` to a file |
135
+ | `publish_suite` | run on Connect; **`dry_run` defaults to true**, since a live run bills the AWS account |
136
+
137
+ Each tool carries MCP annotations: read-only tools say so, and `publish_suite` is marked as reaching an external system. Works with `mcp` 1.x and 2.x.
138
+
139
+ ## Development
140
+
141
+ ```bash
142
+ cd clients/python
143
+ python -m venv .venv && . .venv/bin/activate
144
+ pip install -e ".[dev,mcp]"
145
+ pytest # against an in-memory fake of the API
146
+ DITEKO_URL=http://localhost:8888 pytest # also runs the live test against a local stack
147
+ ```
@@ -0,0 +1,9 @@
1
+ diteko/__init__.py,sha256=363wbvJr5w8gffrrjGYPuOCrGfP_SANJsnYzM7224-s,569
2
+ diteko/_http.py,sha256=Hk02TgDmnLXtgnoIAx6JE7IHPROrN1VDbAMtZhYnVq0,2465
3
+ diteko/mcp_server.py,sha256=pxlM3PnwIPmC5KL90ZdC6r9LVinsLm_Jy83QJYj-Xzc,5619
4
+ diteko/sdk.py,sha256=Q2RczIc-fME4J29KlD_b6gAm50qfaH88hM6cFYUPSa4,19198
5
+ diteko-0.1.0.dist-info/METADATA,sha256=74ECbggZOkhM-HWMRBpBob2v7eb2p_opbER5RPGe4_s,6172
6
+ diteko-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
7
+ diteko-0.1.0.dist-info/entry_points.txt,sha256=oTJxDSGXTDoFmTelRfGKWUQ1L8qaBE3PVfc_MrFRyno,54
8
+ diteko-0.1.0.dist-info/licenses/LICENSE,sha256=QIt3DrYQ3kwXLLojBU8WMLzohCjpBGi2Z-fldHQL5A8,1075
9
+ diteko-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ diteko-mcp = diteko.mcp_server:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Lehlohonolo Sehako
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.