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.
- {diteko-0.2.0 → diteko-0.3.0}/CHANGELOG.md +19 -0
- {diteko-0.2.0 → diteko-0.3.0}/PKG-INFO +55 -1
- {diteko-0.2.0 → diteko-0.3.0}/README.md +35 -0
- diteko-0.3.0/examples/cxblueprint_to_excel.ipynb +179 -0
- diteko-0.3.0/examples/cxblueprint_to_excel.py +56 -0
- {diteko-0.2.0 → diteko-0.3.0}/pyproject.toml +1 -1
- diteko-0.3.0/src/diteko/__init__.py +41 -0
- {diteko-0.2.0 → diteko-0.3.0}/src/diteko/mcp_server.py +1 -1
- {diteko-0.2.0 → diteko-0.3.0}/src/diteko/sdk.py +185 -6
- {diteko-0.2.0 → diteko-0.3.0}/tests/fake_api.py +4 -3
- {diteko-0.2.0 → diteko-0.3.0}/tests/test_sdk.py +47 -0
- diteko-0.2.0/src/diteko/__init__.py +0 -12
- {diteko-0.2.0 → diteko-0.3.0}/.gitignore +0 -0
- {diteko-0.2.0 → diteko-0.3.0}/LICENSE +0 -0
- {diteko-0.2.0 → diteko-0.3.0}/src/diteko/_http.py +0 -0
- {diteko-0.2.0 → diteko-0.3.0}/tests/conftest.py +0 -0
- {diteko-0.2.0 → diteko-0.3.0}/tests/test_contract.py +0 -0
- {diteko-0.2.0 → diteko-0.3.0}/tests/test_live.py +0 -0
- {diteko-0.2.0 → diteko-0.3.0}/tests/test_mcp.py +0 -0
|
@@ -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.
|
|
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.
|
|
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(
|
|
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(
|
|
271
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
"""``
|
|
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"] >
|
|
102
|
+
if r["polls"] > 3:
|
|
103
103
|
r["status"] = "COMPLETED"
|
|
104
|
-
|
|
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
|