diteko 0.2.0__tar.gz → 0.4.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,33 @@ 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.4.0] - 2026-10-11
8
+
9
+ ### Added
10
+
11
+ - `Verdict.attempts` and `Verdict.passed_on_rerun`. A server on API 1.2 re-runs a test
12
+ that failed, once, because Connect's prompt recognition sometimes misses a prompt the
13
+ flow played; a pass on the re-run is reported as a pass with `attempts == 2`. Older
14
+ servers leave `attempts` as `None`.
15
+
16
+ ## [0.3.0] - 2026-10-11
17
+
18
+ ### Added
19
+
20
+ - `Run.wait(progress=True)` keeps a line on stderr up to date while a run evolves; pass
21
+ a callable instead to receive a `RunProgress` whenever the generation or status changes.
22
+ - Typed views alongside the raw responses: `Run.info` (`RunProgress`), `Project.info`
23
+ (`ProjectInfo`: grammar, latest run, suite, last execution), `Execution.results`
24
+ (`Verdict`s with Connect's message) and `Execution.inconclusive`.
25
+ - `Diteko()` reads `DITEKO_URL` when no address is given, as `diteko-mcp` already did.
26
+ - A getting-started guide in this README, and `examples/cxblueprint_to_excel.py` (and the
27
+ same as a notebook): from a cxblueprint flow to an Excel test script.
28
+
29
+ ### Fixed
30
+
31
+ - A project's last execution no longer reads as RUNNING for a moment after the execution
32
+ reported COMPLETED (fixed server-side).
33
+
7
34
  ## [0.2.0] - 2026-10-11
8
35
 
9
36
  ### Added
@@ -34,5 +61,7 @@ All notable changes to the `diteko` Python package. The format follows
34
61
  - `diteko-mcp`, an MCP server exposing the same pipeline as eight tools, on `mcp`
35
62
  1.x or 2.x.
36
63
 
64
+ [0.4.0]: https://pypi.org/project/diteko/0.4.0/
65
+ [0.3.0]: https://pypi.org/project/diteko/0.3.0/
37
66
  [0.2.0]: https://pypi.org/project/diteko/0.2.0/
38
67
  [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.4.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,33 @@ 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.4.0] - 2026-10-11
221
+
222
+ ### Added
223
+
224
+ - `Verdict.attempts` and `Verdict.passed_on_rerun`. A server on API 1.2 re-runs a test
225
+ that failed, once, because Connect's prompt recognition sometimes misses a prompt the
226
+ flow played; a pass on the re-run is reported as a pass with `attempts == 2`. Older
227
+ servers leave `attempts` as `None`.
228
+
229
+ ## [0.3.0] - 2026-10-11
230
+
231
+ ### Added
232
+
233
+ - `Run.wait(progress=True)` keeps a line on stderr up to date while a run evolves; pass
234
+ a callable instead to receive a `RunProgress` whenever the generation or status changes.
235
+ - Typed views alongside the raw responses: `Run.info` (`RunProgress`), `Project.info`
236
+ (`ProjectInfo`: grammar, latest run, suite, last execution), `Execution.results`
237
+ (`Verdict`s with Connect's message) and `Execution.inconclusive`.
238
+ - `Diteko()` reads `DITEKO_URL` when no address is given, as `diteko-mcp` already did.
239
+ - A getting-started guide in this README, and `examples/cxblueprint_to_excel.py` (and the
240
+ same as a notebook): from a cxblueprint flow to an Excel test script.
241
+
242
+ ### Fixed
243
+
244
+ - A project's last execution no longer reads as RUNNING for a moment after the execution
245
+ reported COMPLETED (fixed server-side).
246
+
185
247
  ## [0.2.0] - 2026-10-11
186
248
 
187
249
  ### Added
@@ -212,5 +274,7 @@ All notable changes to the `diteko` Python package. The format follows
212
274
  - `diteko-mcp`, an MCP server exposing the same pipeline as eight tools, on `mcp`
213
275
  1.x or 2.x.
214
276
 
277
+ [0.4.0]: https://pypi.org/project/diteko/0.4.0/
278
+ [0.3.0]: https://pypi.org/project/diteko/0.3.0/
215
279
  [0.2.0]: https://pypi.org/project/diteko/0.2.0/
216
280
  [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.4.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.4.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,135 @@ 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
+ attempts: int | None = None
185
+ """Executions started: 2 when the test failed and the server re-ran it (API 1.2; None from older servers)."""
186
+
187
+ @property
188
+ def conclusive(self) -> bool:
189
+ """PASSED or FAILED. Anything else (ERRORED: Connect could not start it; SKIPPED; a dry run's transcode) says nothing about the flow."""
190
+ return self.status in ("PASSED", "FAILED")
191
+
192
+ @property
193
+ def passed_on_rerun(self) -> bool:
194
+ """Failed once, then passed when re-run: most likely a prompt Connect's recognition missed, not a defect."""
195
+ return self.status == "PASSED" and (self.attempts or 1) > 1
196
+
197
+
198
+ @dataclass(frozen=True)
199
+ class GrammarInfo:
200
+ id: int
201
+ name: str | None
202
+ valid: bool | None
203
+
204
+
205
+ @dataclass(frozen=True)
206
+ class RunInfo:
207
+ id: int
208
+ name: str | None
209
+ status: str | None
210
+ tests: int
211
+ generation: int | None
212
+ generations: int | None
213
+
214
+
215
+ @dataclass(frozen=True)
216
+ class ExecutionInfo:
217
+ upload_id: str
218
+ status: str | None
219
+ dry_run: bool
220
+ passed: int
221
+ failed: int
222
+ total: int
223
+
224
+
225
+ @dataclass(frozen=True)
226
+ class ProjectInfo:
227
+ """A project's place in the pipeline, typed. ``Project.summary`` keeps the raw response."""
228
+
229
+ flow_id: int
230
+ name: str
231
+ next_step: str
232
+ connect_flow_id: str | None
233
+ grammars: int
234
+ grammar: GrammarInfo | None
235
+ runs: int
236
+ latest_run: RunInfo | None
237
+ suite_run_id: int | None
238
+ last_execution: ExecutionInfo | None
239
+
240
+ @classmethod
241
+ def of(cls, raw: dict[str, Any]) -> "ProjectInfo":
242
+ g, r, e = raw.get("grammar"), raw.get("latestRun"), raw.get("lastExecution")
243
+ return cls(
244
+ flow_id=int(raw["flowId"]),
245
+ name=raw.get("name", ""),
246
+ next_step=raw.get("nextStep", ""),
247
+ connect_flow_id=raw.get("awsFlowId"),
248
+ grammars=int(raw.get("grammarCount") or 0),
249
+ grammar=GrammarInfo(int(g["id"]), g.get("name"), g.get("valid")) if g else None,
250
+ runs=int(raw.get("runCount") or 0),
251
+ latest_run=RunInfo(
252
+ int(r["id"]), r.get("name"), r.get("status"), int(r.get("testCount") or 0), r.get("currentGeneration"), r.get("totalGenerations")
253
+ )
254
+ if r
255
+ else None,
256
+ suite_run_id=raw.get("suiteRunId"),
257
+ last_execution=ExecutionInfo(
258
+ 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)
259
+ )
260
+ if e
261
+ else None,
262
+ )
263
+
264
+
134
265
  @dataclass
135
266
  class Test:
136
267
  """One generated test: a call through the flow."""
@@ -177,6 +308,18 @@ class Execution:
177
308
  def failed(self) -> int:
178
309
  return sum(1 for v in self.verdicts.values() if v == "FAILED")
179
310
 
311
+ @property
312
+ def results(self) -> list[Verdict]:
313
+ """Every test's outcome, with Connect's message where there is one."""
314
+ return [
315
+ Verdict(e.get("testId", ""), e.get("status", ""), e.get("message"), e.get("attempts")) for e in self.raw.get("executions", [])
316
+ ]
317
+
318
+ @property
319
+ def inconclusive(self) -> int:
320
+ """Tests that ended neither PASSED nor FAILED: they say nothing about the flow."""
321
+ return sum(1 for v in self.results if not v.conclusive)
322
+
180
323
  def refresh(self) -> "Execution":
181
324
  self.raw = self._http.get(f"/test-cases/connect-runs/{self.upload_id}")
182
325
  return self
@@ -263,17 +406,42 @@ class Run:
263
406
  def status(self) -> str:
264
407
  return self.progress.get("status", "INITIALIZING")
265
408
 
409
+ @property
410
+ def info(self) -> RunProgress:
411
+ """The latest progress, typed (as of the last refresh)."""
412
+ return RunProgress.of(self.progress, self.id)
413
+
266
414
  def refresh(self) -> "Run":
267
415
  self.progress = self._http.get(f"/evolution-runs/{self.id}/progress")
268
416
  return self
269
417
 
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."""
418
+ def wait(
419
+ self,
420
+ timeout: float = 3600,
421
+ poll: float = 2,
422
+ progress: bool | Callable[[RunProgress], Any] = False,
423
+ ) -> "Run":
424
+ """Block until the run finishes; raises DitekoError if it failed, TimeoutError if it is slow.
425
+
426
+ ``progress=True`` keeps one line on stderr up to date; a callable is called with a
427
+ :class:`RunProgress` whenever the generation or the status changes, ending with the final state.
428
+ """
272
429
  deadline = time.monotonic() + timeout
273
- while self.refresh().status in _RUNNING:
430
+ report = _progress_printer() if progress is True else (progress or None)
431
+ last: tuple[int, str] | None = None
432
+ while True:
433
+ self.refresh()
434
+ now = (self.info.generation, self.info.status)
435
+ if report and now != last:
436
+ last = now
437
+ report(self.info)
438
+ if self.status not in _RUNNING:
439
+ break
274
440
  if time.monotonic() > deadline:
275
441
  raise TimeoutError(f"Run {self.id} still {self.status} after {timeout}s")
276
442
  time.sleep(poll)
443
+ if progress is True:
444
+ print(file=sys.stderr)
277
445
  if self.status != "COMPLETED":
278
446
  raise DitekoError(f"Run {self.id} ended {self.status}", body=self.progress)
279
447
  return self
@@ -296,6 +464,19 @@ class Run:
296
464
  return self.refresh()
297
465
 
298
466
 
467
+ def _progress_printer() -> Callable[[RunProgress], None]:
468
+ """One line on stderr, rewritten in place where the terminal allows it."""
469
+ tty = sys.stderr.isatty()
470
+
471
+ def report(p: RunProgress) -> None:
472
+ if tty:
473
+ print(f"\r{p}".ljust(80), end="", file=sys.stderr, flush=True)
474
+ else:
475
+ print(p, file=sys.stderr, flush=True)
476
+
477
+ return report
478
+
479
+
299
480
  # --------------------------------------------------------------------------- project
300
481
 
301
482
 
@@ -327,6 +508,11 @@ class Project:
327
508
  def connect_flow_id(self) -> str | None:
328
509
  return self.summary.get("awsFlowId")
329
510
 
511
+ @property
512
+ def info(self) -> ProjectInfo:
513
+ """Grammar, latest run, suite and last execution, typed (as of the last refresh)."""
514
+ return ProjectInfo.of(self.summary)
515
+
330
516
  def refresh(self) -> "Project":
331
517
  self.summary = self._http.get(f"/projects/{self.flow_id}")
332
518
  return self
@@ -433,15 +619,17 @@ class Diteko:
433
619
 
434
620
  def __init__(
435
621
  self,
436
- url: str = "http://localhost:8888",
622
+ url: str | None = None,
437
623
  *,
438
624
  api_key: str | None = None,
439
625
  timeout: float = 60.0,
440
626
  transport: Any = None,
441
627
  check_version: bool = True,
442
628
  ):
443
- """``api_key`` defaults to ``DITEKO_API_KEY`` and is needed when the server requires keys.
629
+ """``url`` defaults to ``DITEKO_URL``, then ``http://localhost:8888``. ``api_key`` defaults to
630
+ ``DITEKO_API_KEY`` and is needed when the server requires keys.
444
631
  ``check_version`` makes the first call confirm the server speaks this SDK's API major version."""
632
+ url = url or os.environ.get("DITEKO_URL") or "http://localhost:8888"
445
633
  self._http = Http(url, timeout=timeout, transport=transport, check_version=check_version, api_key=api_key)
446
634
  self.projects = Projects(self._http)
447
635
 
@@ -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", "attempts": 2}, {"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,52 @@ 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.results[0].passed_on_rerun and execution.results[0].attempts == 2
207
+ assert not execution.results[1].passed_on_rerun
208
+ assert (execution.passed, execution.failed, execution.inconclusive) == (1, 1, 1)
209
+
210
+
211
+ def test_the_address_comes_from_diteko_url_when_not_given(api, monkeypatch):
212
+ monkeypatch.setenv("DITEKO_URL", "http://diteko.internal:9999")
213
+ dk = Diteko(transport=httpx.MockTransport(api.handler))
214
+ assert str(dk._http._client.base_url) == "http://diteko.internal:9999/api/"
215
+ monkeypatch.delenv("DITEKO_URL")
216
+ 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