diteko 0.2.0__tar.gz → 0.3.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.
@@ -4,6 +4,24 @@ All notable changes to the `diteko` Python package. The format follows
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and versions follow
5
5
  [Semantic Versioning](https://semver.org/) as described under “Versioning” in the README.
6
6
 
7
+ ## [0.3.0] - 2026-10-11
8
+
9
+ ### Added
10
+
11
+ - `Run.wait(progress=True)` keeps a line on stderr up to date while a run evolves; pass
12
+ a callable instead to receive a `RunProgress` whenever the generation or status changes.
13
+ - Typed views alongside the raw responses: `Run.info` (`RunProgress`), `Project.info`
14
+ (`ProjectInfo`: grammar, latest run, suite, last execution), `Execution.results`
15
+ (`Verdict`s with Connect's message) and `Execution.inconclusive`.
16
+ - `Diteko()` reads `DITEKO_URL` when no address is given, as `diteko-mcp` already did.
17
+ - A getting-started guide in this README, and `examples/cxblueprint_to_excel.py` (and the
18
+ same as a notebook): from a cxblueprint flow to an Excel test script.
19
+
20
+ ### Fixed
21
+
22
+ - A project's last execution no longer reads as RUNNING for a moment after the execution
23
+ reported COMPLETED (fixed server-side).
24
+
7
25
  ## [0.2.0] - 2026-10-11
8
26
 
9
27
  ### Added
@@ -34,5 +52,6 @@ All notable changes to the `diteko` Python package. The format follows
34
52
  - `diteko-mcp`, an MCP server exposing the same pipeline as eight tools, on `mcp`
35
53
  1.x or 2.x.
36
54
 
55
+ [0.3.0]: https://pypi.org/project/diteko/0.3.0/
37
56
  [0.2.0]: https://pypi.org/project/diteko/0.2.0/
38
57
  [0.1.0]: https://pypi.org/project/diteko/0.1.0/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: diteko
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Generate, export and run tests for Amazon Connect contact flows with Diteko (Grammatical Evolution).
5
5
  Author: Lehlohonolo Sehako
6
6
  License-Expression: MIT
@@ -46,6 +46,41 @@ pip install "diteko[mcp]" # + MCP server for Claude Desktop / Cursor / VS
46
46
  pip install "diteko[cxblueprint]"
47
47
  ```
48
48
 
49
+ ## Getting started
50
+
51
+ 1. **Find your Diteko.** Diteko is self-hosted: you need the address of a running deployment, such as `http://localhost:8888` when you run it yourself.
52
+ 2. **Get a key, if it asks for one.** When a deployment requires API keys, whoever runs it creates one for you under Settings › API keys. `WRITE` is enough for everything here except live runs on Connect. Put it in the environment:
53
+
54
+ ```bash
55
+ export DITEKO_URL=https://diteko.example.internal
56
+ export DITEKO_API_KEY=dk_... # only if the deployment requires keys
57
+ ```
58
+
59
+ 3. **Check the connection:**
60
+
61
+ ```python
62
+ from diteko import Diteko
63
+
64
+ dk = Diteko() # DITEKO_URL and DITEKO_API_KEY from the environment
65
+ print(dk.version()) # {'api': '1.1', 'server': '…', 'auth': 'keys'}
66
+ print(dk.whoami()) # the key in use, or None when keys are off
67
+ ```
68
+
69
+ 4. **Run the example.** `examples/cxblueprint_to_excel.py`, also as a notebook, builds a flow with cxblueprint, evolves a suite and writes the Excel test script, in about a minute. Both ship in the source distribution: `pip download --no-binary :all: --no-deps diteko`, then unpack it.
70
+
71
+ ## Watching a run, and typed results
72
+
73
+ ```python
74
+ run = project.evolve(preset="standard").wait(progress=True) # one line on stderr, kept up to date
75
+ run = project.evolve(preset="standard").wait(progress=print) # or any callable, given a RunProgress
76
+ run.info.best_fitness, run.info.generation, run.info.generations
77
+
78
+ project.refresh().info # ProjectInfo: grammar, latest run, suite, last execution, typed
79
+ execution = suite.publish_to_connect(dry_run=True)
80
+ execution.results # [Verdict(test_id, status, message), ...]
81
+ execution.passed, execution.failed, execution.inconclusive # inconclusive: Connect could not start it, etc.
82
+ ```
83
+
49
84
  ## The pipeline
50
85
 
51
86
  ```python
@@ -182,6 +217,24 @@ All notable changes to the `diteko` Python package. The format follows
182
217
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and versions follow
183
218
  [Semantic Versioning](https://semver.org/) as described under “Versioning” in the README.
184
219
 
220
+ ## [0.3.0] - 2026-10-11
221
+
222
+ ### Added
223
+
224
+ - `Run.wait(progress=True)` keeps a line on stderr up to date while a run evolves; pass
225
+ a callable instead to receive a `RunProgress` whenever the generation or status changes.
226
+ - Typed views alongside the raw responses: `Run.info` (`RunProgress`), `Project.info`
227
+ (`ProjectInfo`: grammar, latest run, suite, last execution), `Execution.results`
228
+ (`Verdict`s with Connect's message) and `Execution.inconclusive`.
229
+ - `Diteko()` reads `DITEKO_URL` when no address is given, as `diteko-mcp` already did.
230
+ - A getting-started guide in this README, and `examples/cxblueprint_to_excel.py` (and the
231
+ same as a notebook): from a cxblueprint flow to an Excel test script.
232
+
233
+ ### Fixed
234
+
235
+ - A project's last execution no longer reads as RUNNING for a moment after the execution
236
+ reported COMPLETED (fixed server-side).
237
+
185
238
  ## [0.2.0] - 2026-10-11
186
239
 
187
240
  ### Added
@@ -212,5 +265,6 @@ All notable changes to the `diteko` Python package. The format follows
212
265
  - `diteko-mcp`, an MCP server exposing the same pipeline as eight tools, on `mcp`
213
266
  1.x or 2.x.
214
267
 
268
+ [0.3.0]: https://pypi.org/project/diteko/0.3.0/
215
269
  [0.2.0]: https://pypi.org/project/diteko/0.2.0/
216
270
  [0.1.0]: https://pypi.org/project/diteko/0.1.0/
@@ -16,6 +16,41 @@ pip install "diteko[mcp]" # + MCP server for Claude Desktop / Cursor / VS
16
16
  pip install "diteko[cxblueprint]"
17
17
  ```
18
18
 
19
+ ## Getting started
20
+
21
+ 1. **Find your Diteko.** Diteko is self-hosted: you need the address of a running deployment, such as `http://localhost:8888` when you run it yourself.
22
+ 2. **Get a key, if it asks for one.** When a deployment requires API keys, whoever runs it creates one for you under Settings › API keys. `WRITE` is enough for everything here except live runs on Connect. Put it in the environment:
23
+
24
+ ```bash
25
+ export DITEKO_URL=https://diteko.example.internal
26
+ export DITEKO_API_KEY=dk_... # only if the deployment requires keys
27
+ ```
28
+
29
+ 3. **Check the connection:**
30
+
31
+ ```python
32
+ from diteko import Diteko
33
+
34
+ dk = Diteko() # DITEKO_URL and DITEKO_API_KEY from the environment
35
+ print(dk.version()) # {'api': '1.1', 'server': '…', 'auth': 'keys'}
36
+ print(dk.whoami()) # the key in use, or None when keys are off
37
+ ```
38
+
39
+ 4. **Run the example.** `examples/cxblueprint_to_excel.py`, also as a notebook, builds a flow with cxblueprint, evolves a suite and writes the Excel test script, in about a minute. Both ship in the source distribution: `pip download --no-binary :all: --no-deps diteko`, then unpack it.
40
+
41
+ ## Watching a run, and typed results
42
+
43
+ ```python
44
+ run = project.evolve(preset="standard").wait(progress=True) # one line on stderr, kept up to date
45
+ run = project.evolve(preset="standard").wait(progress=print) # or any callable, given a RunProgress
46
+ run.info.best_fitness, run.info.generation, run.info.generations
47
+
48
+ project.refresh().info # ProjectInfo: grammar, latest run, suite, last execution, typed
49
+ execution = suite.publish_to_connect(dry_run=True)
50
+ execution.results # [Verdict(test_id, status, message), ...]
51
+ execution.passed, execution.failed, execution.inconclusive # inconclusive: Connect could not start it, etc.
52
+ ```
53
+
19
54
  ## The pipeline
20
55
 
21
56
  ```python
@@ -0,0 +1,179 @@
1
+ {
2
+ "cells": [
3
+ {
4
+ "cell_type": "markdown",
5
+ "metadata": {},
6
+ "source": [
7
+ "# From a cxblueprint flow to an Excel test script\n",
8
+ "\n",
9
+ "Build a phone menu with [cxblueprint](https://pypi.org/project/cxblueprint/), then let Diteko derive its grammar, evolve a suite of test calls, and write the Excel script that business testers follow by hand.\n",
10
+ "\n",
11
+ "You need a running Diteko. Set `DITEKO_URL` (and `DITEKO_API_KEY` if your deployment requires keys) before starting the notebook, or pass them to `Diteko(...)` below."
12
+ ]
13
+ },
14
+ {
15
+ "cell_type": "code",
16
+ "metadata": {},
17
+ "execution_count": null,
18
+ "outputs": [],
19
+ "source": [
20
+ "%pip install -q \"diteko[cxblueprint]\""
21
+ ]
22
+ },
23
+ {
24
+ "cell_type": "markdown",
25
+ "metadata": {},
26
+ "source": [
27
+ "## 1. A flow, built the cxblueprint way"
28
+ ]
29
+ },
30
+ {
31
+ "cell_type": "code",
32
+ "metadata": {},
33
+ "execution_count": null,
34
+ "outputs": [],
35
+ "source": [
36
+ "from cxblueprint import Flow\n",
37
+ "\n",
38
+ "flow = Flow.build(\"Burger Order\")\n",
39
+ "welcome = flow.play_prompt(\"Welcome to Burger Palace!\")\n",
40
+ "menu = flow.get_input(\"Press 1 for Classic Burger or 2 for Veggie Burger\", timeout=10)\n",
41
+ "welcome.then(menu)\n",
42
+ "classic = flow.play_prompt(\"You selected Classic Burger. Your order is confirmed!\")\n",
43
+ "veggie = flow.play_prompt(\"You selected Veggie Burger. Your order is confirmed!\")\n",
44
+ "sorry = flow.play_prompt(\"Invalid selection. Goodbye.\")\n",
45
+ "bye = flow.disconnect()\n",
46
+ "menu.when(\"1\", classic).when(\"2\", veggie).otherwise(sorry)\n",
47
+ "menu.on_error(\"InputTimeLimitExceeded\", sorry).on_error(\"NoMatchingCondition\", sorry).on_error(\"NoMatchingError\", sorry)\n",
48
+ "for block in (classic, veggie, sorry):\n",
49
+ " block.then(bye)"
50
+ ]
51
+ },
52
+ {
53
+ "cell_type": "markdown",
54
+ "metadata": {},
55
+ "source": [
56
+ "## 2. Register it with Diteko\n",
57
+ "\n",
58
+ "The flow is compiled here and uploaded as JSON. The first call also checks that the server speaks this SDK's API version."
59
+ ]
60
+ },
61
+ {
62
+ "cell_type": "code",
63
+ "metadata": {},
64
+ "execution_count": null,
65
+ "outputs": [],
66
+ "source": [
67
+ "import os, time\n",
68
+ "from diteko import Diteko\n",
69
+ "\n",
70
+ "dk = Diteko(os.environ.get(\"DITEKO_URL\", \"http://localhost:8888\"))\n",
71
+ "print(dk.version())\n",
72
+ "project = dk.projects.from_cxblueprint(flow, name=f\"Burger Order example {int(time.time())}\")\n",
73
+ "project"
74
+ ]
75
+ },
76
+ {
77
+ "cell_type": "markdown",
78
+ "metadata": {},
79
+ "source": [
80
+ "## 3. Its grammar: everything a caller can do\n",
81
+ "\n",
82
+ "Editable as BNF or JSON; the two are exact reflections of each other."
83
+ ]
84
+ },
85
+ {
86
+ "cell_type": "code",
87
+ "metadata": {},
88
+ "execution_count": null,
89
+ "outputs": [],
90
+ "source": [
91
+ "grammar = project.grammar.generate()\n",
92
+ "print(grammar.to_bnf())"
93
+ ]
94
+ },
95
+ {
96
+ "cell_type": "markdown",
97
+ "metadata": {},
98
+ "source": [
99
+ "## 4. Evolve a suite\n",
100
+ "\n",
101
+ "`progress=True` keeps a line updated while the run breeds test calls. `quick` is 40 calls over 15 generations."
102
+ ]
103
+ },
104
+ {
105
+ "cell_type": "code",
106
+ "metadata": {},
107
+ "execution_count": null,
108
+ "outputs": [],
109
+ "source": [
110
+ "run = project.evolve(preset=\"quick\", focus=\"coverage\", seed=1).wait(progress=True)\n",
111
+ "run.info"
112
+ ]
113
+ },
114
+ {
115
+ "cell_type": "markdown",
116
+ "metadata": {},
117
+ "source": [
118
+ "## 5. The suite, read as calls"
119
+ ]
120
+ },
121
+ {
122
+ "cell_type": "code",
123
+ "metadata": {},
124
+ "execution_count": null,
125
+ "outputs": [],
126
+ "source": [
127
+ "suite = run.suite(selection=\"coverage\")\n",
128
+ "for test in suite:\n",
129
+ " print(test.test_id, \"presses\", \" \".join(test.keys) or \"nothing\", f\"fitness {test.fitness:.2f}\")\n",
130
+ " for step in test.steps:\n",
131
+ " print(\" \", step)"
132
+ ]
133
+ },
134
+ {
135
+ "cell_type": "markdown",
136
+ "metadata": {},
137
+ "source": [
138
+ "## 6. The Excel test script, and a dry run on Connect\n",
139
+ "\n",
140
+ "The workbook follows ISO/IEC/IEEE 29119-3: a cover sheet, plain-language steps with expected results, and Pass / Fail / Blocked / Skipped columns. The dry run translates every test to Connect's native format without publishing or billing anything."
141
+ ]
142
+ },
143
+ {
144
+ "cell_type": "code",
145
+ "metadata": {},
146
+ "execution_count": null,
147
+ "outputs": [],
148
+ "source": [
149
+ "path = suite.export_xlsx(\"burger-order-tests.xlsx\")\n",
150
+ "print(\"Excel test script:\", path)\n",
151
+ "\n",
152
+ "execution = suite.publish_to_connect(dry_run=True)\n",
153
+ "for verdict in execution.results:\n",
154
+ " print(verdict.test_id, verdict.status)"
155
+ ]
156
+ },
157
+ {
158
+ "cell_type": "code",
159
+ "metadata": {},
160
+ "execution_count": null,
161
+ "outputs": [],
162
+ "source": [
163
+ "project.refresh().info"
164
+ ]
165
+ }
166
+ ],
167
+ "metadata": {
168
+ "kernelspec": {
169
+ "display_name": "Python 3",
170
+ "language": "python",
171
+ "name": "python3"
172
+ },
173
+ "language_info": {
174
+ "name": "python"
175
+ }
176
+ },
177
+ "nbformat": 4,
178
+ "nbformat_minor": 5
179
+ }
@@ -0,0 +1,56 @@
1
+ """From a cxblueprint flow to an Excel test script, with Diteko.
2
+
3
+ pip install "diteko[cxblueprint]"
4
+ DITEKO_URL=http://localhost:8888 python cxblueprint_to_excel.py
5
+ # add DITEKO_API_KEY=dk_... if your Diteko requires keys
6
+
7
+ It builds a small ordering line with cxblueprint, lets Diteko derive its grammar and
8
+ evolve a suite of test calls, prints the suite, writes the Excel script that business
9
+ testers follow by hand, and does a dry run on Connect (nothing is published or billed).
10
+ """
11
+
12
+ import os
13
+ import time
14
+
15
+ from cxblueprint import Flow
16
+
17
+ from diteko import Diteko
18
+
19
+ # 1. A flow, built the cxblueprint way.
20
+ flow = Flow.build("Burger Order")
21
+ welcome = flow.play_prompt("Welcome to Burger Palace!")
22
+ menu = flow.get_input("Press 1 for Classic Burger or 2 for Veggie Burger", timeout=10)
23
+ welcome.then(menu)
24
+ classic = flow.play_prompt("You selected Classic Burger. Your order is confirmed!")
25
+ veggie = flow.play_prompt("You selected Veggie Burger. Your order is confirmed!")
26
+ sorry = flow.play_prompt("Invalid selection. Goodbye.")
27
+ bye = flow.disconnect()
28
+ menu.when("1", classic).when("2", veggie).otherwise(sorry)
29
+ menu.on_error("InputTimeLimitExceeded", sorry).on_error("NoMatchingCondition", sorry).on_error("NoMatchingError", sorry)
30
+ for block in (classic, veggie, sorry):
31
+ block.then(bye)
32
+
33
+ with Diteko(os.environ.get("DITEKO_URL", "http://localhost:8888")) as dk:
34
+ print("Server:", dk.version())
35
+
36
+ # 2. Register it as a project (compiled locally, uploaded as JSON).
37
+ project = dk.projects.from_cxblueprint(flow, name=f"Burger Order example {int(time.time())}")
38
+ print(project)
39
+
40
+ # 3. A grammar of everything a caller can do, then a quick evolution run.
41
+ grammar = project.grammar.generate()
42
+ print(grammar.to_bnf())
43
+ run = project.evolve(preset="quick", focus="coverage", seed=1).wait(progress=True)
44
+
45
+ # 4. The suite, read as calls.
46
+ suite = run.suite(selection="coverage")
47
+ for test in suite:
48
+ print(f"{test.test_id} keys {' '.join(test.keys) or '(none)':<12} fitness {test.fitness:.2f}")
49
+
50
+ # 5. The manual test script, and a dry run on Connect.
51
+ path = suite.export_xlsx("burger-order-tests.xlsx")
52
+ print("Excel test script:", path)
53
+ execution = suite.publish_to_connect(dry_run=True)
54
+ print(f"Dry run {execution.status.lower()}: {len(execution.results)} tests translated, nothing published")
55
+
56
+ print(project.refresh().info)
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "diteko"
7
- version = "0.2.0"
7
+ version = "0.3.0"
8
8
  description = "Generate, export and run tests for Amazon Connect contact flows with Diteko (Grammatical Evolution)."
9
9
  dynamic = ["readme"]
10
10
  requires-python = ">=3.10"
@@ -0,0 +1,41 @@
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 (
10
+ FOCUS,
11
+ SIZES,
12
+ Diteko,
13
+ Execution,
14
+ Grammar,
15
+ Project,
16
+ ProjectInfo,
17
+ Projects,
18
+ Run,
19
+ RunProgress,
20
+ Suite,
21
+ Test,
22
+ Verdict,
23
+ )
24
+
25
+ __all__ = [
26
+ "Diteko",
27
+ "DitekoError",
28
+ "Execution",
29
+ "FOCUS",
30
+ "Grammar",
31
+ "Project",
32
+ "ProjectInfo",
33
+ "Projects",
34
+ "Run",
35
+ "RunProgress",
36
+ "SIZES",
37
+ "Suite",
38
+ "Test",
39
+ "Verdict",
40
+ ]
41
+ __version__ = "0.3.0"
@@ -41,7 +41,7 @@ _client: Diteko | None = None
41
41
  def _dk() -> Diteko:
42
42
  global _client
43
43
  if _client is None:
44
- _client = Diteko(os.environ.get("DITEKO_URL", "http://localhost:8888"))
44
+ _client = Diteko() # DITEKO_URL and DITEKO_API_KEY from the environment
45
45
  return _client
46
46
 
47
47
 
@@ -7,10 +7,12 @@ flow -> grammar -> evolve (a run) -> suite -> run on Connect -> results.
7
7
  from __future__ import annotations
8
8
 
9
9
  import json as _json
10
+ import os
11
+ import sys
10
12
  import time
11
13
  from dataclasses import dataclass, field
12
14
  from pathlib import Path
13
- from typing import Any, Iterable, Literal
15
+ from typing import Any, Callable, Iterable, Literal
14
16
 
15
17
  from ._http import DitekoError, Http
16
18
 
@@ -131,6 +133,128 @@ class GrammarAccess:
131
133
  # --------------------------------------------------------------------------- suite and execution
132
134
 
133
135
 
136
+ # --------------------------------------------------------------------------- typed views
137
+
138
+
139
+ @dataclass(frozen=True)
140
+ class RunProgress:
141
+ """Where an evolution run stands: one generation's figures."""
142
+
143
+ run_id: int
144
+ status: str
145
+ generation: int
146
+ generations: int
147
+ best_fitness: float | None
148
+ average_fitness: float | None
149
+ population: int | None
150
+
151
+ @property
152
+ def done(self) -> bool:
153
+ return self.status not in _RUNNING
154
+
155
+ @property
156
+ def fraction(self) -> float:
157
+ """0.0 to 1.0, by generations completed."""
158
+ return self.generation / self.generations if self.generations else 0.0
159
+
160
+ def __str__(self) -> str:
161
+ best = "n/a" if self.best_fitness is None else f"{self.best_fitness:.3f}"
162
+ return f"Run {self.run_id}: generation {self.generation}/{self.generations}, best fitness {best}, {self.status.lower()}"
163
+
164
+ @classmethod
165
+ def of(cls, raw: dict[str, Any], run_id: int) -> "RunProgress":
166
+ return cls(
167
+ run_id=int(raw.get("runId") or run_id),
168
+ status=raw.get("status") or "INITIALIZING",
169
+ generation=int(raw.get("currentGeneration") or 0),
170
+ generations=int(raw.get("totalGenerations") or 0),
171
+ best_fitness=raw.get("bestFitness"),
172
+ average_fitness=raw.get("averageFitness"),
173
+ population=raw.get("populationSize"),
174
+ )
175
+
176
+
177
+ @dataclass(frozen=True)
178
+ class Verdict:
179
+ """What Connect decided about one test in an execution."""
180
+
181
+ test_id: str
182
+ status: str
183
+ message: str | None = None
184
+
185
+ @property
186
+ def conclusive(self) -> bool:
187
+ """PASSED or FAILED. Anything else (ERRORED: Connect could not start it; SKIPPED; a dry run's transcode) says nothing about the flow."""
188
+ return self.status in ("PASSED", "FAILED")
189
+
190
+
191
+ @dataclass(frozen=True)
192
+ class GrammarInfo:
193
+ id: int
194
+ name: str | None
195
+ valid: bool | None
196
+
197
+
198
+ @dataclass(frozen=True)
199
+ class RunInfo:
200
+ id: int
201
+ name: str | None
202
+ status: str | None
203
+ tests: int
204
+ generation: int | None
205
+ generations: int | None
206
+
207
+
208
+ @dataclass(frozen=True)
209
+ class ExecutionInfo:
210
+ upload_id: str
211
+ status: str | None
212
+ dry_run: bool
213
+ passed: int
214
+ failed: int
215
+ total: int
216
+
217
+
218
+ @dataclass(frozen=True)
219
+ class ProjectInfo:
220
+ """A project's place in the pipeline, typed. ``Project.summary`` keeps the raw response."""
221
+
222
+ flow_id: int
223
+ name: str
224
+ next_step: str
225
+ connect_flow_id: str | None
226
+ grammars: int
227
+ grammar: GrammarInfo | None
228
+ runs: int
229
+ latest_run: RunInfo | None
230
+ suite_run_id: int | None
231
+ last_execution: ExecutionInfo | None
232
+
233
+ @classmethod
234
+ def of(cls, raw: dict[str, Any]) -> "ProjectInfo":
235
+ g, r, e = raw.get("grammar"), raw.get("latestRun"), raw.get("lastExecution")
236
+ return cls(
237
+ flow_id=int(raw["flowId"]),
238
+ name=raw.get("name", ""),
239
+ next_step=raw.get("nextStep", ""),
240
+ connect_flow_id=raw.get("awsFlowId"),
241
+ grammars=int(raw.get("grammarCount") or 0),
242
+ grammar=GrammarInfo(int(g["id"]), g.get("name"), g.get("valid")) if g else None,
243
+ runs=int(raw.get("runCount") or 0),
244
+ latest_run=RunInfo(
245
+ int(r["id"]), r.get("name"), r.get("status"), int(r.get("testCount") or 0), r.get("currentGeneration"), r.get("totalGenerations")
246
+ )
247
+ if r
248
+ else None,
249
+ suite_run_id=raw.get("suiteRunId"),
250
+ last_execution=ExecutionInfo(
251
+ e.get("uploadId", ""), e.get("status"), bool(e.get("dryRun")), int(e.get("passed") or 0), int(e.get("failed") or 0), int(e.get("total") or 0)
252
+ )
253
+ if e
254
+ else None,
255
+ )
256
+
257
+
134
258
  @dataclass
135
259
  class Test:
136
260
  """One generated test: a call through the flow."""
@@ -177,6 +301,16 @@ class Execution:
177
301
  def failed(self) -> int:
178
302
  return sum(1 for v in self.verdicts.values() if v == "FAILED")
179
303
 
304
+ @property
305
+ def results(self) -> list[Verdict]:
306
+ """Every test's outcome, with Connect's message where there is one."""
307
+ return [Verdict(e.get("testId", ""), e.get("status", ""), e.get("message")) for e in self.raw.get("executions", [])]
308
+
309
+ @property
310
+ def inconclusive(self) -> int:
311
+ """Tests that ended neither PASSED nor FAILED: they say nothing about the flow."""
312
+ return sum(1 for v in self.results if not v.conclusive)
313
+
180
314
  def refresh(self) -> "Execution":
181
315
  self.raw = self._http.get(f"/test-cases/connect-runs/{self.upload_id}")
182
316
  return self
@@ -263,17 +397,42 @@ class Run:
263
397
  def status(self) -> str:
264
398
  return self.progress.get("status", "INITIALIZING")
265
399
 
400
+ @property
401
+ def info(self) -> RunProgress:
402
+ """The latest progress, typed (as of the last refresh)."""
403
+ return RunProgress.of(self.progress, self.id)
404
+
266
405
  def refresh(self) -> "Run":
267
406
  self.progress = self._http.get(f"/evolution-runs/{self.id}/progress")
268
407
  return self
269
408
 
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."""
409
+ def wait(
410
+ self,
411
+ timeout: float = 3600,
412
+ poll: float = 2,
413
+ progress: bool | Callable[[RunProgress], Any] = False,
414
+ ) -> "Run":
415
+ """Block until the run finishes; raises DitekoError if it failed, TimeoutError if it is slow.
416
+
417
+ ``progress=True`` keeps one line on stderr up to date; a callable is called with a
418
+ :class:`RunProgress` whenever the generation or the status changes, ending with the final state.
419
+ """
272
420
  deadline = time.monotonic() + timeout
273
- while self.refresh().status in _RUNNING:
421
+ report = _progress_printer() if progress is True else (progress or None)
422
+ last: tuple[int, str] | None = None
423
+ while True:
424
+ self.refresh()
425
+ now = (self.info.generation, self.info.status)
426
+ if report and now != last:
427
+ last = now
428
+ report(self.info)
429
+ if self.status not in _RUNNING:
430
+ break
274
431
  if time.monotonic() > deadline:
275
432
  raise TimeoutError(f"Run {self.id} still {self.status} after {timeout}s")
276
433
  time.sleep(poll)
434
+ if progress is True:
435
+ print(file=sys.stderr)
277
436
  if self.status != "COMPLETED":
278
437
  raise DitekoError(f"Run {self.id} ended {self.status}", body=self.progress)
279
438
  return self
@@ -296,6 +455,19 @@ class Run:
296
455
  return self.refresh()
297
456
 
298
457
 
458
+ def _progress_printer() -> Callable[[RunProgress], None]:
459
+ """One line on stderr, rewritten in place where the terminal allows it."""
460
+ tty = sys.stderr.isatty()
461
+
462
+ def report(p: RunProgress) -> None:
463
+ if tty:
464
+ print(f"\r{p}".ljust(80), end="", file=sys.stderr, flush=True)
465
+ else:
466
+ print(p, file=sys.stderr, flush=True)
467
+
468
+ return report
469
+
470
+
299
471
  # --------------------------------------------------------------------------- project
300
472
 
301
473
 
@@ -327,6 +499,11 @@ class Project:
327
499
  def connect_flow_id(self) -> str | None:
328
500
  return self.summary.get("awsFlowId")
329
501
 
502
+ @property
503
+ def info(self) -> ProjectInfo:
504
+ """Grammar, latest run, suite and last execution, typed (as of the last refresh)."""
505
+ return ProjectInfo.of(self.summary)
506
+
330
507
  def refresh(self) -> "Project":
331
508
  self.summary = self._http.get(f"/projects/{self.flow_id}")
332
509
  return self
@@ -433,15 +610,17 @@ class Diteko:
433
610
 
434
611
  def __init__(
435
612
  self,
436
- url: str = "http://localhost:8888",
613
+ url: str | None = None,
437
614
  *,
438
615
  api_key: str | None = None,
439
616
  timeout: float = 60.0,
440
617
  transport: Any = None,
441
618
  check_version: bool = True,
442
619
  ):
443
- """``api_key`` defaults to ``DITEKO_API_KEY`` and is needed when the server requires keys.
620
+ """``url`` defaults to ``DITEKO_URL``, then ``http://localhost:8888``. ``api_key`` defaults to
621
+ ``DITEKO_API_KEY`` and is needed when the server requires keys.
444
622
  ``check_version`` makes the first call confirm the server speaks this SDK's API major version."""
623
+ url = url or os.environ.get("DITEKO_URL") or "http://localhost:8888"
445
624
  self._http = Http(url, timeout=timeout, transport=transport, check_version=check_version, api_key=api_key)
446
625
  self.projects = Projects(self._http)
447
626
 
@@ -99,9 +99,10 @@ class FakeApi:
99
99
  if m == "GET" and (mt := re.fullmatch(r"/evolution-runs/(\d+)/progress", path)):
100
100
  r = self.runs[int(mt[1])]
101
101
  r["polls"] += 1
102
- if r["polls"] > 1:
102
+ if r["polls"] > 3:
103
103
  r["status"] = "COMPLETED"
104
- return ok({"status": r["status"], "currentGeneration": 5, "totalGenerations": 5, "bestFitness": 0.8})
104
+ gen = min(r["polls"] * 2, 5)
105
+ return ok({"runId": r["id"], "status": r["status"], "currentGeneration": gen, "totalGenerations": 5, "bestFitness": 0.4 + gen / 10})
105
106
  if m == "POST" and path == "/test-cases/prioritize":
106
107
  return ok(
107
108
  [
@@ -116,7 +117,7 @@ class FakeApi:
116
117
  if m == "POST" and path == "/test-cases/upload-to-connect":
117
118
  return ok({"uploadId": "u-1", "status": "RUNNING", "dryRun": body["dryRun"], "executions": []}, 202)
118
119
  if m == "GET" and path == "/test-cases/connect-runs/u-1":
119
- return ok({"uploadId": "u-1", "status": "COMPLETED", "dryRun": True, "executions": [{"testId": "GE-0001", "status": "PASSED"}, {"testId": "GE-0002", "status": "FAILED"}]})
120
+ return ok({"uploadId": "u-1", "status": "COMPLETED", "dryRun": True, "executions": [{"testId": "GE-0001", "status": "PASSED"}, {"testId": "GE-0002", "status": "FAILED", "message": "Expected prompt not heard"}, {"testId": "GE-0003", "status": "ERRORED", "message": "Connect could not start the test"}]})
120
121
  if m == "GET" and path == "/settings/connect":
121
122
  return ok({"enabled": False, "maxConcurrent": 5, "executionBudgetSeconds": 240})
122
123
  return ok({"title": f"no fake for {m} {path}"}, 404)
@@ -165,3 +165,50 @@ def test_a_wrong_key_is_reported_as_refused(api):
165
165
 
166
166
  def test_whoami_is_none_when_the_server_does_not_require_keys(dk):
167
167
  assert dk.whoami() is None
168
+
169
+
170
+ def test_wait_reports_progress_each_generation(dk, monkeypatch):
171
+ monkeypatch.setattr("time.sleep", lambda s: None)
172
+ project = dk.projects.from_json(FLOW, name="progress")
173
+ seen = []
174
+ run = project.evolve(preset="quick").wait(progress=seen.append)
175
+ states = [(p.generation, p.status) for p in seen]
176
+ assert len(states) >= 2 and len(states) == len(set(states)) # once per change of generation or status
177
+ assert [g for g, _ in states] == sorted(g for g, _ in states)
178
+ assert seen[-1].done and seen[-1].generation == 5 and seen[-1].fraction == 1.0
179
+ assert run.info.best_fitness == pytest.approx(0.9)
180
+ assert "generation 5/5" in str(seen[-1])
181
+
182
+
183
+ def test_wait_can_print_a_progress_line(dk, monkeypatch, capsys):
184
+ monkeypatch.setattr("time.sleep", lambda s: None)
185
+ dk.projects.from_json(FLOW, name="printed").evolve(preset="quick").wait(progress=True)
186
+ assert "best fitness" in capsys.readouterr().err
187
+
188
+
189
+ def test_project_info_is_typed(dk, monkeypatch):
190
+ monkeypatch.setattr("time.sleep", lambda s: None)
191
+ project = dk.projects.from_json(FLOW, name="typed")
192
+ project.grammar.generate()
193
+ project.evolve(preset="quick").wait()
194
+ info = project.refresh().info
195
+ assert info.name == "typed" and info.next_step == "RUN"
196
+ assert info.grammar is not None and info.grammar.valid
197
+ assert info.suite_run_id is not None
198
+
199
+
200
+ def test_execution_results_separate_inconclusive_tests(dk, monkeypatch):
201
+ monkeypatch.setattr("time.sleep", lambda s: None)
202
+ project = dk.projects.from_json(FLOW, name="verdicts")
203
+ execution = project.evolve(preset="quick").wait().suite().publish_to_connect()
204
+ assert [(v.test_id, v.conclusive) for v in execution.results] == [("GE-0001", True), ("GE-0002", True), ("GE-0003", False)]
205
+ assert execution.results[1].message == "Expected prompt not heard"
206
+ assert (execution.passed, execution.failed, execution.inconclusive) == (1, 1, 1)
207
+
208
+
209
+ def test_the_address_comes_from_diteko_url_when_not_given(api, monkeypatch):
210
+ monkeypatch.setenv("DITEKO_URL", "http://diteko.internal:9999")
211
+ dk = Diteko(transport=httpx.MockTransport(api.handler))
212
+ assert str(dk._http._client.base_url) == "http://diteko.internal:9999/api/"
213
+ monkeypatch.delenv("DITEKO_URL")
214
+ assert str(Diteko(transport=httpx.MockTransport(api.handler))._http._client.base_url) == "http://localhost:8888/api/"
@@ -1,12 +0,0 @@
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.2.0"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes