zamp-sdk 0.0.2__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.
- zamp_sdk-0.0.2/LICENSE +21 -0
- zamp_sdk-0.0.2/PKG-INFO +196 -0
- zamp_sdk-0.0.2/README.md +166 -0
- zamp_sdk-0.0.2/pyproject.toml +74 -0
- zamp_sdk-0.0.2/zamp_sdk/__init__.py +4 -0
- zamp_sdk-0.0.2/zamp_sdk/action_executor/__init__.py +3 -0
- zamp_sdk-0.0.2/zamp_sdk/action_executor/action_executor.py +119 -0
- zamp_sdk-0.0.2/zamp_sdk/action_executor/constants/__init__.py +21 -0
- zamp_sdk-0.0.2/zamp_sdk/action_executor/constants/polling.py +3 -0
- zamp_sdk-0.0.2/zamp_sdk/action_executor/constants/statuses.py +22 -0
- zamp_sdk-0.0.2/zamp_sdk/action_executor/models/__init__.py +17 -0
- zamp_sdk-0.0.2/zamp_sdk/action_executor/models/retry_policy.py +24 -0
- zamp_sdk-0.0.2/zamp_sdk/action_executor/models/sdk_config.py +8 -0
- zamp_sdk-0.0.2/zamp_sdk/action_executor/tests/__init__.py +0 -0
- zamp_sdk-0.0.2/zamp_sdk/action_executor/tests/conftest.py +11 -0
- zamp_sdk-0.0.2/zamp_sdk/action_executor/tests/test_action_executor.py +287 -0
- zamp_sdk-0.0.2/zamp_sdk/action_executor/tests/test_http_client.py +149 -0
- zamp_sdk-0.0.2/zamp_sdk/action_executor/tests/test_models.py +41 -0
- zamp_sdk-0.0.2/zamp_sdk/action_executor/utils/__init__.py +6 -0
- zamp_sdk-0.0.2/zamp_sdk/action_executor/utils/http_client.py +130 -0
- zamp_sdk-0.0.2/zamp_sdk/py.typed +0 -0
zamp_sdk-0.0.2/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 Zamp
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
zamp_sdk-0.0.2/PKG-INFO
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: zamp-sdk
|
|
3
|
+
Version: 0.0.2
|
|
4
|
+
Summary: Customer-facing SDK for executing actions on the Zamp platform
|
|
5
|
+
License: MIT
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Keywords: zamp,sdk,actions,automation
|
|
8
|
+
Author: Zamp Engineering
|
|
9
|
+
Author-email: engineering@zamp.com
|
|
10
|
+
Requires-Python: >=3.12,<4.0
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Framework :: AsyncIO
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
19
|
+
Classifier: Typing :: Typed
|
|
20
|
+
Requires-Dist: aiohttp
|
|
21
|
+
Requires-Dist: pydantic
|
|
22
|
+
Requires-Dist: structlog
|
|
23
|
+
Project-URL: Changelog, https://github.com/Zampfi/zamp-sdk/releases
|
|
24
|
+
Project-URL: Documentation, https://github.com/Zampfi/zamp-sdk#readme
|
|
25
|
+
Project-URL: Homepage, https://github.com/Zampfi/zamp-sdk
|
|
26
|
+
Project-URL: Issues, https://github.com/Zampfi/zamp-sdk/issues
|
|
27
|
+
Project-URL: Repository, https://github.com/Zampfi/zamp-sdk
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
|
|
30
|
+
# Zamp SDK
|
|
31
|
+
|
|
32
|
+
[](https://pypi.org/project/zamp-sdk/)
|
|
33
|
+
[](https://pypi.org/project/zamp-sdk/)
|
|
34
|
+
[](LICENSE)
|
|
35
|
+
|
|
36
|
+
The official Python SDK for executing actions on the [Zamp](https://zamp.ai) platform.
|
|
37
|
+
|
|
38
|
+
## Installation
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
pip install zamp-sdk
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Or with [Poetry](https://python-poetry.org/):
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
poetry add zamp-sdk
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Quick Start
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
import asyncio
|
|
54
|
+
from zamp_sdk import ActionExecutor
|
|
55
|
+
|
|
56
|
+
async def main():
|
|
57
|
+
result = await ActionExecutor.execute(
|
|
58
|
+
"send_invoice",
|
|
59
|
+
{"invoice_id": "inv_123"},
|
|
60
|
+
base_url="https://api.zamp.ai",
|
|
61
|
+
auth_token="your-api-token",
|
|
62
|
+
)
|
|
63
|
+
print(result)
|
|
64
|
+
|
|
65
|
+
asyncio.run(main())
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
### Using environment variables
|
|
69
|
+
|
|
70
|
+
Set `ZAMP_BASE_URL` and `ZAMP_AUTH_TOKEN` in your environment, then call without explicit config:
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
result = await ActionExecutor.execute("send_invoice", {"invoice_id": "inv_123"})
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## API Reference
|
|
77
|
+
|
|
78
|
+
### `ActionExecutor.execute()`
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
@staticmethod
|
|
82
|
+
async def execute(
|
|
83
|
+
action_name: str,
|
|
84
|
+
params: Any,
|
|
85
|
+
*,
|
|
86
|
+
base_url: str | None = None,
|
|
87
|
+
auth_token: str | None = None,
|
|
88
|
+
summary: str | None = None,
|
|
89
|
+
return_type: type | None = None,
|
|
90
|
+
action_retry_policy: RetryPolicy | None = None,
|
|
91
|
+
action_start_to_close_timeout: timedelta | None = None,
|
|
92
|
+
) -> Any
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
| Parameter | Type | Required | Description |
|
|
96
|
+
|-----------|------|----------|-------------|
|
|
97
|
+
| `action_name` | `str` | Yes | Name of the registered action to execute |
|
|
98
|
+
| `params` | `Any` | Yes | Input parameters for the action |
|
|
99
|
+
| `base_url` | `str \| None` | No | Zamp API base URL. Falls back to `ZAMP_BASE_URL` env var |
|
|
100
|
+
| `auth_token` | `str \| None` | No | API authentication token. Falls back to `ZAMP_AUTH_TOKEN` env var |
|
|
101
|
+
| `summary` | `str \| None` | No | Human-readable description of the execution |
|
|
102
|
+
| `return_type` | `type \| None` | No | Pydantic model to validate the result against |
|
|
103
|
+
| `action_retry_policy` | `RetryPolicy \| None` | No | Retry configuration for the action |
|
|
104
|
+
| `action_start_to_close_timeout` | `timedelta \| None` | No | Maximum execution time for the action |
|
|
105
|
+
|
|
106
|
+
### `RetryPolicy`
|
|
107
|
+
|
|
108
|
+
```python
|
|
109
|
+
from zamp_sdk import RetryPolicy
|
|
110
|
+
|
|
111
|
+
policy = RetryPolicy(
|
|
112
|
+
initial_interval=timedelta(seconds=30),
|
|
113
|
+
maximum_attempts=11,
|
|
114
|
+
maximum_interval=timedelta(minutes=15),
|
|
115
|
+
backoff_coefficient=1.5,
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
# Or use the default configuration:
|
|
119
|
+
policy = RetryPolicy.default()
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
| Field | Type | Default (via `.default()`) |
|
|
123
|
+
|-------|------|---------------------------|
|
|
124
|
+
| `initial_interval` | `timedelta` | 30 seconds |
|
|
125
|
+
| `maximum_attempts` | `int` | 11 |
|
|
126
|
+
| `maximum_interval` | `timedelta` | 15 minutes |
|
|
127
|
+
| `backoff_coefficient` | `float` | 1.5 |
|
|
128
|
+
|
|
129
|
+
## Configuration
|
|
130
|
+
|
|
131
|
+
| Environment Variable | Description |
|
|
132
|
+
|---------------------|-------------|
|
|
133
|
+
| `ZAMP_BASE_URL` | Base URL of the Zamp API (e.g. `https://api.zamp.ai`) |
|
|
134
|
+
| `ZAMP_AUTH_TOKEN` | API authentication token |
|
|
135
|
+
|
|
136
|
+
Explicit parameters passed to `ActionExecutor.execute()` take precedence over environment variables.
|
|
137
|
+
|
|
138
|
+
## Error Handling
|
|
139
|
+
|
|
140
|
+
| Exception | When |
|
|
141
|
+
|-----------|------|
|
|
142
|
+
| `HttpClientError` | HTTP request fails (non-2xx status, network error, timeout) |
|
|
143
|
+
| `RuntimeError` | Action reaches a terminal failure state (FAILED, CANCELED, TERMINATED, TIMED_OUT) |
|
|
144
|
+
| `TimeoutError` | Polling for action result exceeds the timeout limit |
|
|
145
|
+
| `KeyError` | Required environment variable is missing and no explicit value was provided |
|
|
146
|
+
|
|
147
|
+
```python
|
|
148
|
+
from zamp_sdk.action_executor.utils import HttpClientError
|
|
149
|
+
|
|
150
|
+
try:
|
|
151
|
+
result = await ActionExecutor.execute("my_action", params)
|
|
152
|
+
except HttpClientError as e:
|
|
153
|
+
print(f"HTTP error {e.status_code}: {e.message}")
|
|
154
|
+
except RuntimeError as e:
|
|
155
|
+
print(f"Action failed: {e}")
|
|
156
|
+
except TimeoutError as e:
|
|
157
|
+
print(f"Timed out: {e}")
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
## Development
|
|
161
|
+
|
|
162
|
+
```bash
|
|
163
|
+
# Clone and install
|
|
164
|
+
git clone https://github.com/Zampfi/zamp-sdk.git
|
|
165
|
+
cd zamp-sdk
|
|
166
|
+
make install
|
|
167
|
+
|
|
168
|
+
# Run all checks (lint + type-check + tests)
|
|
169
|
+
make check
|
|
170
|
+
|
|
171
|
+
# Individual targets
|
|
172
|
+
make lint # ruff check + format check
|
|
173
|
+
make lint-fix # auto-fix lint issues
|
|
174
|
+
make format # format code
|
|
175
|
+
make type-check # mypy
|
|
176
|
+
make test # pytest with coverage
|
|
177
|
+
make clean # remove build artifacts
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
## Contributing
|
|
181
|
+
|
|
182
|
+
1. Create a feature branch from `main`
|
|
183
|
+
2. Make your changes
|
|
184
|
+
3. Run `make check` to verify lint, type-check, and tests pass
|
|
185
|
+
4. Open a pull request
|
|
186
|
+
|
|
187
|
+
Pre-commit hooks are configured -- install them with:
|
|
188
|
+
|
|
189
|
+
```bash
|
|
190
|
+
poetry run pre-commit install
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
## License
|
|
194
|
+
|
|
195
|
+
[MIT](LICENSE)
|
|
196
|
+
|
zamp_sdk-0.0.2/README.md
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
# Zamp SDK
|
|
2
|
+
|
|
3
|
+
[](https://pypi.org/project/zamp-sdk/)
|
|
4
|
+
[](https://pypi.org/project/zamp-sdk/)
|
|
5
|
+
[](LICENSE)
|
|
6
|
+
|
|
7
|
+
The official Python SDK for executing actions on the [Zamp](https://zamp.ai) platform.
|
|
8
|
+
|
|
9
|
+
## Installation
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pip install zamp-sdk
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Or with [Poetry](https://python-poetry.org/):
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
poetry add zamp-sdk
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Quick Start
|
|
22
|
+
|
|
23
|
+
```python
|
|
24
|
+
import asyncio
|
|
25
|
+
from zamp_sdk import ActionExecutor
|
|
26
|
+
|
|
27
|
+
async def main():
|
|
28
|
+
result = await ActionExecutor.execute(
|
|
29
|
+
"send_invoice",
|
|
30
|
+
{"invoice_id": "inv_123"},
|
|
31
|
+
base_url="https://api.zamp.ai",
|
|
32
|
+
auth_token="your-api-token",
|
|
33
|
+
)
|
|
34
|
+
print(result)
|
|
35
|
+
|
|
36
|
+
asyncio.run(main())
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
### Using environment variables
|
|
40
|
+
|
|
41
|
+
Set `ZAMP_BASE_URL` and `ZAMP_AUTH_TOKEN` in your environment, then call without explicit config:
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
result = await ActionExecutor.execute("send_invoice", {"invoice_id": "inv_123"})
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## API Reference
|
|
48
|
+
|
|
49
|
+
### `ActionExecutor.execute()`
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
@staticmethod
|
|
53
|
+
async def execute(
|
|
54
|
+
action_name: str,
|
|
55
|
+
params: Any,
|
|
56
|
+
*,
|
|
57
|
+
base_url: str | None = None,
|
|
58
|
+
auth_token: str | None = None,
|
|
59
|
+
summary: str | None = None,
|
|
60
|
+
return_type: type | None = None,
|
|
61
|
+
action_retry_policy: RetryPolicy | None = None,
|
|
62
|
+
action_start_to_close_timeout: timedelta | None = None,
|
|
63
|
+
) -> Any
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
| Parameter | Type | Required | Description |
|
|
67
|
+
|-----------|------|----------|-------------|
|
|
68
|
+
| `action_name` | `str` | Yes | Name of the registered action to execute |
|
|
69
|
+
| `params` | `Any` | Yes | Input parameters for the action |
|
|
70
|
+
| `base_url` | `str \| None` | No | Zamp API base URL. Falls back to `ZAMP_BASE_URL` env var |
|
|
71
|
+
| `auth_token` | `str \| None` | No | API authentication token. Falls back to `ZAMP_AUTH_TOKEN` env var |
|
|
72
|
+
| `summary` | `str \| None` | No | Human-readable description of the execution |
|
|
73
|
+
| `return_type` | `type \| None` | No | Pydantic model to validate the result against |
|
|
74
|
+
| `action_retry_policy` | `RetryPolicy \| None` | No | Retry configuration for the action |
|
|
75
|
+
| `action_start_to_close_timeout` | `timedelta \| None` | No | Maximum execution time for the action |
|
|
76
|
+
|
|
77
|
+
### `RetryPolicy`
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
from zamp_sdk import RetryPolicy
|
|
81
|
+
|
|
82
|
+
policy = RetryPolicy(
|
|
83
|
+
initial_interval=timedelta(seconds=30),
|
|
84
|
+
maximum_attempts=11,
|
|
85
|
+
maximum_interval=timedelta(minutes=15),
|
|
86
|
+
backoff_coefficient=1.5,
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
# Or use the default configuration:
|
|
90
|
+
policy = RetryPolicy.default()
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
| Field | Type | Default (via `.default()`) |
|
|
94
|
+
|-------|------|---------------------------|
|
|
95
|
+
| `initial_interval` | `timedelta` | 30 seconds |
|
|
96
|
+
| `maximum_attempts` | `int` | 11 |
|
|
97
|
+
| `maximum_interval` | `timedelta` | 15 minutes |
|
|
98
|
+
| `backoff_coefficient` | `float` | 1.5 |
|
|
99
|
+
|
|
100
|
+
## Configuration
|
|
101
|
+
|
|
102
|
+
| Environment Variable | Description |
|
|
103
|
+
|---------------------|-------------|
|
|
104
|
+
| `ZAMP_BASE_URL` | Base URL of the Zamp API (e.g. `https://api.zamp.ai`) |
|
|
105
|
+
| `ZAMP_AUTH_TOKEN` | API authentication token |
|
|
106
|
+
|
|
107
|
+
Explicit parameters passed to `ActionExecutor.execute()` take precedence over environment variables.
|
|
108
|
+
|
|
109
|
+
## Error Handling
|
|
110
|
+
|
|
111
|
+
| Exception | When |
|
|
112
|
+
|-----------|------|
|
|
113
|
+
| `HttpClientError` | HTTP request fails (non-2xx status, network error, timeout) |
|
|
114
|
+
| `RuntimeError` | Action reaches a terminal failure state (FAILED, CANCELED, TERMINATED, TIMED_OUT) |
|
|
115
|
+
| `TimeoutError` | Polling for action result exceeds the timeout limit |
|
|
116
|
+
| `KeyError` | Required environment variable is missing and no explicit value was provided |
|
|
117
|
+
|
|
118
|
+
```python
|
|
119
|
+
from zamp_sdk.action_executor.utils import HttpClientError
|
|
120
|
+
|
|
121
|
+
try:
|
|
122
|
+
result = await ActionExecutor.execute("my_action", params)
|
|
123
|
+
except HttpClientError as e:
|
|
124
|
+
print(f"HTTP error {e.status_code}: {e.message}")
|
|
125
|
+
except RuntimeError as e:
|
|
126
|
+
print(f"Action failed: {e}")
|
|
127
|
+
except TimeoutError as e:
|
|
128
|
+
print(f"Timed out: {e}")
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
## Development
|
|
132
|
+
|
|
133
|
+
```bash
|
|
134
|
+
# Clone and install
|
|
135
|
+
git clone https://github.com/Zampfi/zamp-sdk.git
|
|
136
|
+
cd zamp-sdk
|
|
137
|
+
make install
|
|
138
|
+
|
|
139
|
+
# Run all checks (lint + type-check + tests)
|
|
140
|
+
make check
|
|
141
|
+
|
|
142
|
+
# Individual targets
|
|
143
|
+
make lint # ruff check + format check
|
|
144
|
+
make lint-fix # auto-fix lint issues
|
|
145
|
+
make format # format code
|
|
146
|
+
make type-check # mypy
|
|
147
|
+
make test # pytest with coverage
|
|
148
|
+
make clean # remove build artifacts
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
## Contributing
|
|
152
|
+
|
|
153
|
+
1. Create a feature branch from `main`
|
|
154
|
+
2. Make your changes
|
|
155
|
+
3. Run `make check` to verify lint, type-check, and tests pass
|
|
156
|
+
4. Open a pull request
|
|
157
|
+
|
|
158
|
+
Pre-commit hooks are configured -- install them with:
|
|
159
|
+
|
|
160
|
+
```bash
|
|
161
|
+
poetry run pre-commit install
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
## License
|
|
165
|
+
|
|
166
|
+
[MIT](LICENSE)
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
[tool.poetry]
|
|
2
|
+
name = "zamp-sdk"
|
|
3
|
+
version = "0.0.2"
|
|
4
|
+
description = "Customer-facing SDK for executing actions on the Zamp platform"
|
|
5
|
+
authors = ["Zamp Engineering <engineering@zamp.com>"]
|
|
6
|
+
readme = "README.md"
|
|
7
|
+
packages = [{include = "zamp_sdk"}]
|
|
8
|
+
license = "MIT"
|
|
9
|
+
keywords = ["zamp", "sdk", "actions", "automation"]
|
|
10
|
+
classifiers = [
|
|
11
|
+
"Development Status :: 4 - Beta",
|
|
12
|
+
"Intended Audience :: Developers",
|
|
13
|
+
"License :: OSI Approved :: MIT License",
|
|
14
|
+
"Programming Language :: Python :: 3",
|
|
15
|
+
"Programming Language :: Python :: 3.12",
|
|
16
|
+
"Typing :: Typed",
|
|
17
|
+
"Framework :: AsyncIO",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
[tool.poetry.urls]
|
|
21
|
+
Homepage = "https://github.com/Zampfi/zamp-sdk"
|
|
22
|
+
Documentation = "https://github.com/Zampfi/zamp-sdk#readme"
|
|
23
|
+
Repository = "https://github.com/Zampfi/zamp-sdk"
|
|
24
|
+
Issues = "https://github.com/Zampfi/zamp-sdk/issues"
|
|
25
|
+
Changelog = "https://github.com/Zampfi/zamp-sdk/releases"
|
|
26
|
+
|
|
27
|
+
[tool.poetry.dependencies]
|
|
28
|
+
python = "^3.12"
|
|
29
|
+
aiohttp = "*"
|
|
30
|
+
pydantic = "*"
|
|
31
|
+
structlog = "*"
|
|
32
|
+
|
|
33
|
+
[tool.poetry.group.dev.dependencies]
|
|
34
|
+
pytest = "*"
|
|
35
|
+
pytest-asyncio = "*"
|
|
36
|
+
pytest-cov = "*"
|
|
37
|
+
ruff = "*"
|
|
38
|
+
mypy = "*"
|
|
39
|
+
pre-commit = "*"
|
|
40
|
+
diff-cover = "*"
|
|
41
|
+
|
|
42
|
+
[build-system]
|
|
43
|
+
requires = ["poetry-core"]
|
|
44
|
+
build-backend = "poetry.core.masonry.api"
|
|
45
|
+
|
|
46
|
+
[tool.mypy]
|
|
47
|
+
python_version = "3.12"
|
|
48
|
+
warn_return_any = false
|
|
49
|
+
warn_unused_configs = true
|
|
50
|
+
disallow_untyped_defs = false
|
|
51
|
+
disallow_incomplete_defs = false
|
|
52
|
+
check_untyped_defs = false
|
|
53
|
+
show_error_codes = true
|
|
54
|
+
show_column_numbers = true
|
|
55
|
+
show_error_context = true
|
|
56
|
+
pretty = true
|
|
57
|
+
|
|
58
|
+
[tool.ruff]
|
|
59
|
+
target-version = "py312"
|
|
60
|
+
line-length = 120
|
|
61
|
+
|
|
62
|
+
[tool.ruff.lint]
|
|
63
|
+
select = ["E", "F", "I", "W"]
|
|
64
|
+
ignore = ["E501"]
|
|
65
|
+
|
|
66
|
+
[tool.pytest.ini_options]
|
|
67
|
+
asyncio_mode = "auto"
|
|
68
|
+
testpaths = ["zamp_sdk", "tests"]
|
|
69
|
+
addopts = "--cov=zamp_sdk --cov-report=xml --cov-report=term-missing --cov-fail-under=80"
|
|
70
|
+
|
|
71
|
+
[tool.coverage.run]
|
|
72
|
+
relative_files = true
|
|
73
|
+
source = ["zamp_sdk"]
|
|
74
|
+
omit = ["*/tests/*"]
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import os
|
|
3
|
+
from datetime import timedelta
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from zamp_sdk.action_executor.constants import (
|
|
7
|
+
IN_PROGRESS_STATUSES,
|
|
8
|
+
POLL_INITIAL_INTERVAL_SECONDS,
|
|
9
|
+
POLL_MAX_INTERVAL_SECONDS,
|
|
10
|
+
POLL_TIMEOUT_SECONDS,
|
|
11
|
+
SUCCESS_STATUSES,
|
|
12
|
+
TERMINAL_FAILURE_STATUSES,
|
|
13
|
+
)
|
|
14
|
+
from zamp_sdk.action_executor.models import RetryPolicy, SdkConfig
|
|
15
|
+
from zamp_sdk.action_executor.utils import HttpClient
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ActionExecutor:
|
|
19
|
+
"""Entry point for executing actions on the Zamp platform.
|
|
20
|
+
|
|
21
|
+
Configuration can be supplied explicitly via ``base_url`` / ``auth_token``
|
|
22
|
+
keyword arguments, or read automatically from the ``ZAMP_BASE_URL`` and
|
|
23
|
+
``ZAMP_AUTH_TOKEN`` environment variables.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def _resolve_config(
|
|
27
|
+
self,
|
|
28
|
+
base_url: str | None,
|
|
29
|
+
auth_token: str | None,
|
|
30
|
+
) -> SdkConfig:
|
|
31
|
+
"""Build config from explicit values, falling back to environment variables."""
|
|
32
|
+
return SdkConfig(
|
|
33
|
+
base_url=base_url or os.environ["ZAMP_BASE_URL"],
|
|
34
|
+
auth_token=auth_token or os.environ["ZAMP_AUTH_TOKEN"],
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
async def execute(
|
|
38
|
+
self,
|
|
39
|
+
action_name: str,
|
|
40
|
+
params: dict[str, Any],
|
|
41
|
+
*,
|
|
42
|
+
base_url: str | None = None,
|
|
43
|
+
auth_token: str | None = None,
|
|
44
|
+
summary: str | None = None,
|
|
45
|
+
return_type: type | None = None,
|
|
46
|
+
action_retry_policy: RetryPolicy | None = None,
|
|
47
|
+
action_start_to_close_timeout: timedelta | None = None,
|
|
48
|
+
) -> Any:
|
|
49
|
+
config = self._resolve_config(base_url, auth_token)
|
|
50
|
+
|
|
51
|
+
return await self._execute_action(
|
|
52
|
+
action_name=action_name,
|
|
53
|
+
params=params,
|
|
54
|
+
config=config,
|
|
55
|
+
return_type=return_type,
|
|
56
|
+
summary=summary,
|
|
57
|
+
action_retry_policy=action_retry_policy,
|
|
58
|
+
action_start_to_close_timeout=action_start_to_close_timeout,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
async def _execute_action(
|
|
62
|
+
self,
|
|
63
|
+
action_name: str,
|
|
64
|
+
params: dict[str, Any],
|
|
65
|
+
*,
|
|
66
|
+
config: SdkConfig,
|
|
67
|
+
return_type: type | None = None,
|
|
68
|
+
summary: str | None = None,
|
|
69
|
+
action_retry_policy: RetryPolicy | None = None,
|
|
70
|
+
action_start_to_close_timeout: timedelta | None = None,
|
|
71
|
+
) -> Any:
|
|
72
|
+
"""Post to ``{config.base_url}/actions`` and poll until a terminal state."""
|
|
73
|
+
client = HttpClient(
|
|
74
|
+
base_url=config.base_url,
|
|
75
|
+
default_headers={"Authorization": f"Bearer {config.auth_token}"},
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
body: dict = {
|
|
79
|
+
"action_name": action_name,
|
|
80
|
+
"params": params,
|
|
81
|
+
"is_external_action": True,
|
|
82
|
+
}
|
|
83
|
+
if summary is not None:
|
|
84
|
+
body["summary"] = summary
|
|
85
|
+
if action_retry_policy is not None:
|
|
86
|
+
body["retry_policy"] = action_retry_policy.model_dump(mode="json")
|
|
87
|
+
if action_start_to_close_timeout is not None:
|
|
88
|
+
body["start_to_close_timeout_seconds"] = action_start_to_close_timeout.total_seconds()
|
|
89
|
+
|
|
90
|
+
response = await client.post("/actions", data=body)
|
|
91
|
+
action_id = response["id"]
|
|
92
|
+
result = await self._poll_action_result(client, action_id)
|
|
93
|
+
|
|
94
|
+
if return_type and hasattr(return_type, "model_validate"):
|
|
95
|
+
return return_type.model_validate(result)
|
|
96
|
+
return result
|
|
97
|
+
|
|
98
|
+
@staticmethod
|
|
99
|
+
async def _poll_action_result(client: HttpClient, action_id: str) -> Any:
|
|
100
|
+
"""Poll ``GET /actions/{id}`` with exponential backoff until a terminal state."""
|
|
101
|
+
interval = POLL_INITIAL_INTERVAL_SECONDS
|
|
102
|
+
elapsed = 0.0
|
|
103
|
+
|
|
104
|
+
while elapsed < POLL_TIMEOUT_SECONDS:
|
|
105
|
+
await asyncio.sleep(interval)
|
|
106
|
+
elapsed += interval
|
|
107
|
+
|
|
108
|
+
data = await client.get(f"/actions/{action_id}")
|
|
109
|
+
action_status = data["status"]
|
|
110
|
+
|
|
111
|
+
if action_status in SUCCESS_STATUSES:
|
|
112
|
+
return data.get("result")
|
|
113
|
+
if action_status in TERMINAL_FAILURE_STATUSES:
|
|
114
|
+
raise RuntimeError(f"Action {action_id} {action_status}: {data.get('error', 'unknown error')}")
|
|
115
|
+
if action_status not in IN_PROGRESS_STATUSES:
|
|
116
|
+
raise RuntimeError(f"Action {action_id} unexpected status: {action_status}")
|
|
117
|
+
interval = min(interval * 2, POLL_MAX_INTERVAL_SECONDS)
|
|
118
|
+
|
|
119
|
+
raise TimeoutError(f"Action {action_id} did not complete within {POLL_TIMEOUT_SECONDS}s")
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from zamp_sdk.action_executor.constants.polling import (
|
|
2
|
+
POLL_INITIAL_INTERVAL_SECONDS,
|
|
3
|
+
POLL_MAX_INTERVAL_SECONDS,
|
|
4
|
+
POLL_TIMEOUT_SECONDS,
|
|
5
|
+
)
|
|
6
|
+
from zamp_sdk.action_executor.constants.statuses import (
|
|
7
|
+
IN_PROGRESS_STATUSES,
|
|
8
|
+
SUCCESS_STATUSES,
|
|
9
|
+
TERMINAL_FAILURE_STATUSES,
|
|
10
|
+
ActionStatus,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"ActionStatus",
|
|
15
|
+
"IN_PROGRESS_STATUSES",
|
|
16
|
+
"POLL_INITIAL_INTERVAL_SECONDS",
|
|
17
|
+
"POLL_MAX_INTERVAL_SECONDS",
|
|
18
|
+
"POLL_TIMEOUT_SECONDS",
|
|
19
|
+
"SUCCESS_STATUSES",
|
|
20
|
+
"TERMINAL_FAILURE_STATUSES",
|
|
21
|
+
]
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from enum import StrEnum
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class ActionStatus(StrEnum):
|
|
5
|
+
RUNNING = "RUNNING"
|
|
6
|
+
COMPLETED = "COMPLETED"
|
|
7
|
+
FAILED = "FAILED"
|
|
8
|
+
CANCELED = "CANCELED"
|
|
9
|
+
TERMINATED = "TERMINATED"
|
|
10
|
+
TIMED_OUT = "TIMED_OUT"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
SUCCESS_STATUSES = frozenset({ActionStatus.COMPLETED})
|
|
14
|
+
TERMINAL_FAILURE_STATUSES = frozenset(
|
|
15
|
+
{
|
|
16
|
+
ActionStatus.FAILED,
|
|
17
|
+
ActionStatus.CANCELED,
|
|
18
|
+
ActionStatus.TERMINATED,
|
|
19
|
+
ActionStatus.TIMED_OUT,
|
|
20
|
+
}
|
|
21
|
+
)
|
|
22
|
+
IN_PROGRESS_STATUSES = frozenset({ActionStatus.RUNNING})
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from zamp_sdk.action_executor.models.retry_policy import (
|
|
2
|
+
DEFAULT_RETRY_BACKOFF_COEFFICIENT,
|
|
3
|
+
DEFAULT_RETRY_INITIAL_INTERVAL,
|
|
4
|
+
DEFAULT_RETRY_MAXIMUM_ATTEMPTS,
|
|
5
|
+
DEFAULT_RETRY_MAXIMUM_INTERVAL,
|
|
6
|
+
RetryPolicy,
|
|
7
|
+
)
|
|
8
|
+
from zamp_sdk.action_executor.models.sdk_config import SdkConfig
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"DEFAULT_RETRY_BACKOFF_COEFFICIENT",
|
|
12
|
+
"DEFAULT_RETRY_INITIAL_INTERVAL",
|
|
13
|
+
"DEFAULT_RETRY_MAXIMUM_ATTEMPTS",
|
|
14
|
+
"DEFAULT_RETRY_MAXIMUM_INTERVAL",
|
|
15
|
+
"RetryPolicy",
|
|
16
|
+
"SdkConfig",
|
|
17
|
+
]
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from datetime import timedelta
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel
|
|
4
|
+
|
|
5
|
+
DEFAULT_RETRY_INITIAL_INTERVAL = timedelta(seconds=30)
|
|
6
|
+
DEFAULT_RETRY_MAXIMUM_ATTEMPTS = 11
|
|
7
|
+
DEFAULT_RETRY_MAXIMUM_INTERVAL = timedelta(minutes=15)
|
|
8
|
+
DEFAULT_RETRY_BACKOFF_COEFFICIENT = 1.5
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class RetryPolicy(BaseModel):
|
|
12
|
+
initial_interval: timedelta
|
|
13
|
+
maximum_attempts: int
|
|
14
|
+
maximum_interval: timedelta
|
|
15
|
+
backoff_coefficient: float
|
|
16
|
+
|
|
17
|
+
@staticmethod
|
|
18
|
+
def default() -> "RetryPolicy":
|
|
19
|
+
return RetryPolicy(
|
|
20
|
+
initial_interval=DEFAULT_RETRY_INITIAL_INTERVAL,
|
|
21
|
+
maximum_attempts=DEFAULT_RETRY_MAXIMUM_ATTEMPTS,
|
|
22
|
+
maximum_interval=DEFAULT_RETRY_MAXIMUM_INTERVAL,
|
|
23
|
+
backoff_coefficient=DEFAULT_RETRY_BACKOFF_COEFFICIENT,
|
|
24
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
from datetime import timedelta
|
|
2
|
+
from unittest.mock import AsyncMock, MagicMock, patch
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
from zamp_sdk.action_executor.action_executor import ActionExecutor
|
|
7
|
+
from zamp_sdk.action_executor.models import RetryPolicy, SdkConfig
|
|
8
|
+
|
|
9
|
+
_MODULE = "zamp_sdk.action_executor.action_executor"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class TestExecute:
|
|
13
|
+
"""Tests for the public ActionExecutor.execute() entry point."""
|
|
14
|
+
|
|
15
|
+
def _executor(self) -> ActionExecutor:
|
|
16
|
+
return ActionExecutor()
|
|
17
|
+
|
|
18
|
+
async def test_explicit_config_forwarded(self, base_url, auth_token):
|
|
19
|
+
executor = self._executor()
|
|
20
|
+
with patch.object(executor, "_execute_action", new_callable=AsyncMock) as mock:
|
|
21
|
+
mock.return_value = {"result": "ok"}
|
|
22
|
+
|
|
23
|
+
result = await executor.execute(
|
|
24
|
+
"send_invoice",
|
|
25
|
+
{"id": "inv_1"},
|
|
26
|
+
base_url=base_url,
|
|
27
|
+
auth_token=auth_token,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
assert result == {"result": "ok"}
|
|
31
|
+
call_kwargs = mock.call_args.kwargs
|
|
32
|
+
config = call_kwargs["config"]
|
|
33
|
+
assert isinstance(config, SdkConfig)
|
|
34
|
+
assert config.base_url == base_url
|
|
35
|
+
assert config.auth_token == auth_token
|
|
36
|
+
|
|
37
|
+
async def test_falls_back_to_env_vars(self, base_url, auth_token):
|
|
38
|
+
executor = self._executor()
|
|
39
|
+
env = {"ZAMP_BASE_URL": base_url, "ZAMP_AUTH_TOKEN": auth_token}
|
|
40
|
+
with (
|
|
41
|
+
patch.object(executor, "_execute_action", new_callable=AsyncMock) as mock,
|
|
42
|
+
patch.dict("os.environ", env, clear=False),
|
|
43
|
+
):
|
|
44
|
+
mock.return_value = "done"
|
|
45
|
+
result = await executor.execute("my_action", {"k": "v"})
|
|
46
|
+
|
|
47
|
+
assert result == "done"
|
|
48
|
+
mock.assert_awaited_once()
|
|
49
|
+
config = mock.call_args.kwargs["config"]
|
|
50
|
+
assert config.base_url == base_url
|
|
51
|
+
assert config.auth_token == auth_token
|
|
52
|
+
|
|
53
|
+
async def test_raises_when_env_vars_missing(self):
|
|
54
|
+
executor = self._executor()
|
|
55
|
+
with (
|
|
56
|
+
patch.dict("os.environ", {}, clear=True),
|
|
57
|
+
pytest.raises(KeyError, match="ZAMP_BASE_URL"),
|
|
58
|
+
):
|
|
59
|
+
await executor.execute("action", {})
|
|
60
|
+
|
|
61
|
+
async def test_forwards_all_params(self, base_url, auth_token):
|
|
62
|
+
executor = self._executor()
|
|
63
|
+
retry = RetryPolicy.default()
|
|
64
|
+
timeout = timedelta(minutes=5)
|
|
65
|
+
|
|
66
|
+
with patch.object(executor, "_execute_action", new_callable=AsyncMock) as mock:
|
|
67
|
+
mock.return_value = None
|
|
68
|
+
|
|
69
|
+
await executor.execute(
|
|
70
|
+
"action",
|
|
71
|
+
{"x": 1},
|
|
72
|
+
base_url=base_url,
|
|
73
|
+
auth_token=auth_token,
|
|
74
|
+
summary="test summary",
|
|
75
|
+
return_type=dict,
|
|
76
|
+
action_retry_policy=retry,
|
|
77
|
+
action_start_to_close_timeout=timeout,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
call_kwargs = mock.call_args.kwargs
|
|
81
|
+
assert call_kwargs["summary"] == "test summary"
|
|
82
|
+
assert call_kwargs["return_type"] is dict
|
|
83
|
+
assert call_kwargs["action_retry_policy"] is retry
|
|
84
|
+
assert call_kwargs["action_start_to_close_timeout"] == timeout
|
|
85
|
+
|
|
86
|
+
async def test_returns_result(self, base_url, auth_token):
|
|
87
|
+
executor = self._executor()
|
|
88
|
+
with patch.object(executor, "_execute_action", new_callable=AsyncMock) as mock:
|
|
89
|
+
mock.return_value = {"amount": 42}
|
|
90
|
+
|
|
91
|
+
result = await executor.execute(
|
|
92
|
+
"calc",
|
|
93
|
+
{},
|
|
94
|
+
base_url=base_url,
|
|
95
|
+
auth_token=auth_token,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
assert result == {"amount": 42}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class TestExecuteAction:
|
|
102
|
+
"""Tests for the private ActionExecutor._execute_action() method."""
|
|
103
|
+
|
|
104
|
+
def _executor(self) -> ActionExecutor:
|
|
105
|
+
return ActionExecutor()
|
|
106
|
+
|
|
107
|
+
def _make_config(
|
|
108
|
+
self,
|
|
109
|
+
base_url: str = "https://api.zamp.test",
|
|
110
|
+
auth_token: str = "tok",
|
|
111
|
+
) -> SdkConfig:
|
|
112
|
+
return SdkConfig(base_url=base_url, auth_token=auth_token)
|
|
113
|
+
|
|
114
|
+
async def test_builds_correct_post_body(self):
|
|
115
|
+
mock_client = AsyncMock()
|
|
116
|
+
mock_client.post.return_value = {"id": "action-123"}
|
|
117
|
+
mock_client.get.return_value = {"status": "COMPLETED", "result": {"ok": True}}
|
|
118
|
+
|
|
119
|
+
with patch(f"{_MODULE}.HttpClient", return_value=mock_client):
|
|
120
|
+
await self._executor()._execute_action(
|
|
121
|
+
action_name="send_email",
|
|
122
|
+
params={"to": "a@b.com"},
|
|
123
|
+
config=self._make_config(),
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
body = mock_client.post.call_args.kwargs["data"]
|
|
127
|
+
assert body["action_name"] == "send_email"
|
|
128
|
+
assert body["params"] == {"to": "a@b.com"}
|
|
129
|
+
assert body["is_external_action"] is True
|
|
130
|
+
|
|
131
|
+
async def test_includes_optional_fields(self):
|
|
132
|
+
mock_client = AsyncMock()
|
|
133
|
+
mock_client.post.return_value = {"id": "action-456"}
|
|
134
|
+
mock_client.get.return_value = {"status": "COMPLETED", "result": None}
|
|
135
|
+
|
|
136
|
+
retry = RetryPolicy.default()
|
|
137
|
+
|
|
138
|
+
with patch(f"{_MODULE}.HttpClient", return_value=mock_client):
|
|
139
|
+
await self._executor()._execute_action(
|
|
140
|
+
action_name="process",
|
|
141
|
+
params={},
|
|
142
|
+
config=self._make_config(),
|
|
143
|
+
summary="Test summary",
|
|
144
|
+
action_retry_policy=retry,
|
|
145
|
+
action_start_to_close_timeout=timedelta(minutes=10),
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
body = mock_client.post.call_args.kwargs["data"]
|
|
149
|
+
assert body["summary"] == "Test summary"
|
|
150
|
+
assert "retry_policy" in body
|
|
151
|
+
assert body["start_to_close_timeout_seconds"] == 600.0
|
|
152
|
+
|
|
153
|
+
async def test_polls_after_post(self):
|
|
154
|
+
mock_client = AsyncMock()
|
|
155
|
+
mock_client.post.return_value = {"id": "action-789"}
|
|
156
|
+
mock_client.get.return_value = {"status": "COMPLETED", "result": {"val": 1}}
|
|
157
|
+
|
|
158
|
+
with patch(f"{_MODULE}.HttpClient", return_value=mock_client):
|
|
159
|
+
result = await self._executor()._execute_action(
|
|
160
|
+
action_name="calc",
|
|
161
|
+
params={},
|
|
162
|
+
config=self._make_config(),
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
mock_client.get.assert_awaited()
|
|
166
|
+
assert result == {"val": 1}
|
|
167
|
+
|
|
168
|
+
async def test_uses_return_type_model_validate(self):
|
|
169
|
+
mock_client = AsyncMock()
|
|
170
|
+
mock_client.post.return_value = {"id": "action-abc"}
|
|
171
|
+
mock_client.get.return_value = {"status": "COMPLETED", "result": {"x": 1}}
|
|
172
|
+
|
|
173
|
+
mock_model = MagicMock()
|
|
174
|
+
mock_model.model_validate.return_value = "validated"
|
|
175
|
+
|
|
176
|
+
with patch(f"{_MODULE}.HttpClient", return_value=mock_client):
|
|
177
|
+
result = await self._executor()._execute_action(
|
|
178
|
+
action_name="typed",
|
|
179
|
+
params={},
|
|
180
|
+
config=self._make_config(),
|
|
181
|
+
return_type=mock_model,
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
mock_model.model_validate.assert_called_once_with({"x": 1})
|
|
185
|
+
assert result == "validated"
|
|
186
|
+
|
|
187
|
+
async def test_constructs_client_with_auth_header(self):
|
|
188
|
+
mock_client = AsyncMock()
|
|
189
|
+
mock_client.post.return_value = {"id": "action-xyz"}
|
|
190
|
+
mock_client.get.return_value = {"status": "COMPLETED", "result": None}
|
|
191
|
+
|
|
192
|
+
with patch(f"{_MODULE}.HttpClient", return_value=mock_client) as mock_cls:
|
|
193
|
+
await self._executor()._execute_action(
|
|
194
|
+
action_name="test",
|
|
195
|
+
params={},
|
|
196
|
+
config=self._make_config(
|
|
197
|
+
base_url="https://api.zamp.test",
|
|
198
|
+
auth_token="my-token",
|
|
199
|
+
),
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
mock_cls.assert_called_once_with(
|
|
203
|
+
base_url="https://api.zamp.test",
|
|
204
|
+
default_headers={"Authorization": "Bearer my-token"},
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
class TestPollActionResult:
|
|
209
|
+
"""Tests for the private ActionExecutor._poll_action_result() method."""
|
|
210
|
+
|
|
211
|
+
def _executor(self) -> ActionExecutor:
|
|
212
|
+
return ActionExecutor()
|
|
213
|
+
|
|
214
|
+
async def test_returns_result_on_completed(self):
|
|
215
|
+
client = AsyncMock()
|
|
216
|
+
client.get.return_value = {"status": "COMPLETED", "result": {"data": 42}}
|
|
217
|
+
|
|
218
|
+
with patch(f"{_MODULE}.asyncio.sleep", new_callable=AsyncMock):
|
|
219
|
+
result = await self._executor()._poll_action_result(client, "action-1")
|
|
220
|
+
|
|
221
|
+
assert result == {"data": 42}
|
|
222
|
+
|
|
223
|
+
async def test_raises_on_failed(self):
|
|
224
|
+
client = AsyncMock()
|
|
225
|
+
client.get.return_value = {"status": "FAILED", "error": "boom"}
|
|
226
|
+
|
|
227
|
+
with (
|
|
228
|
+
patch(f"{_MODULE}.asyncio.sleep", new_callable=AsyncMock),
|
|
229
|
+
pytest.raises(RuntimeError, match="FAILED.*boom"),
|
|
230
|
+
):
|
|
231
|
+
await self._executor()._poll_action_result(client, "action-2")
|
|
232
|
+
|
|
233
|
+
async def test_raises_on_canceled(self):
|
|
234
|
+
client = AsyncMock()
|
|
235
|
+
client.get.return_value = {"status": "CANCELED", "error": "cancelled"}
|
|
236
|
+
|
|
237
|
+
with (
|
|
238
|
+
patch(f"{_MODULE}.asyncio.sleep", new_callable=AsyncMock),
|
|
239
|
+
pytest.raises(RuntimeError, match="CANCELED"),
|
|
240
|
+
):
|
|
241
|
+
await self._executor()._poll_action_result(client, "action-3")
|
|
242
|
+
|
|
243
|
+
async def test_raises_on_timed_out(self):
|
|
244
|
+
client = AsyncMock()
|
|
245
|
+
client.get.return_value = {"status": "TIMED_OUT", "error": "timeout"}
|
|
246
|
+
|
|
247
|
+
with (
|
|
248
|
+
patch(f"{_MODULE}.asyncio.sleep", new_callable=AsyncMock),
|
|
249
|
+
pytest.raises(RuntimeError, match="TIMED_OUT"),
|
|
250
|
+
):
|
|
251
|
+
await self._executor()._poll_action_result(client, "action-4")
|
|
252
|
+
|
|
253
|
+
async def test_raises_timeout_error_when_poll_exceeds_limit(self):
|
|
254
|
+
client = AsyncMock()
|
|
255
|
+
client.get.return_value = {"status": "RUNNING"}
|
|
256
|
+
|
|
257
|
+
with (
|
|
258
|
+
patch(f"{_MODULE}.asyncio.sleep", new_callable=AsyncMock),
|
|
259
|
+
patch(f"{_MODULE}.POLL_TIMEOUT_SECONDS", 2.0),
|
|
260
|
+
patch(f"{_MODULE}.POLL_INITIAL_INTERVAL_SECONDS", 1.0),
|
|
261
|
+
pytest.raises(TimeoutError, match="did not complete"),
|
|
262
|
+
):
|
|
263
|
+
await self._executor()._poll_action_result(client, "action-5")
|
|
264
|
+
|
|
265
|
+
async def test_polls_until_completed(self):
|
|
266
|
+
client = AsyncMock()
|
|
267
|
+
client.get.side_effect = [
|
|
268
|
+
{"status": "RUNNING"},
|
|
269
|
+
{"status": "RUNNING"},
|
|
270
|
+
{"status": "COMPLETED", "result": {"done": True}},
|
|
271
|
+
]
|
|
272
|
+
|
|
273
|
+
with patch(f"{_MODULE}.asyncio.sleep", new_callable=AsyncMock):
|
|
274
|
+
result = await self._executor()._poll_action_result(client, "action-6")
|
|
275
|
+
|
|
276
|
+
assert result == {"done": True}
|
|
277
|
+
assert client.get.await_count == 3
|
|
278
|
+
|
|
279
|
+
async def test_raises_on_unexpected_status(self):
|
|
280
|
+
client = AsyncMock()
|
|
281
|
+
client.get.return_value = {"status": "UNKNOWN_STATE"}
|
|
282
|
+
|
|
283
|
+
with (
|
|
284
|
+
patch(f"{_MODULE}.asyncio.sleep", new_callable=AsyncMock),
|
|
285
|
+
pytest.raises(RuntimeError, match="unexpected status"),
|
|
286
|
+
):
|
|
287
|
+
await self._executor()._poll_action_result(client, "action-7")
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from unittest.mock import AsyncMock, MagicMock, patch
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
from zamp_sdk.action_executor.utils import HttpClient, HttpClientError
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class TestHttpClientUrlBuilding:
|
|
10
|
+
def test_builds_url_with_base(self):
|
|
11
|
+
client = HttpClient(base_url="https://api.zamp.test")
|
|
12
|
+
assert client._build_url("/actions") == "https://api.zamp.test/actions"
|
|
13
|
+
|
|
14
|
+
def test_builds_url_strips_trailing_slash(self):
|
|
15
|
+
client = HttpClient(base_url="https://api.zamp.test/")
|
|
16
|
+
assert client._build_url("/actions") == "https://api.zamp.test/actions"
|
|
17
|
+
|
|
18
|
+
# If the input URL is already absolute, it should be returned as-is regardless of the base URL.
|
|
19
|
+
def test_builds_url_without_base(self):
|
|
20
|
+
client = HttpClient()
|
|
21
|
+
assert client._build_url("https://full.url/path") == "https://full.url/path"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class TestHttpClientPost:
|
|
25
|
+
async def test_post_with_json_body(self):
|
|
26
|
+
client = HttpClient(base_url="https://api.zamp.test")
|
|
27
|
+
mock_response = AsyncMock()
|
|
28
|
+
mock_response.ok = True
|
|
29
|
+
mock_response.status = 200
|
|
30
|
+
mock_response.text = AsyncMock(return_value=json.dumps({"id": "123"}))
|
|
31
|
+
|
|
32
|
+
mock_session = AsyncMock()
|
|
33
|
+
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
|
|
34
|
+
mock_session.__aexit__ = AsyncMock(return_value=False)
|
|
35
|
+
|
|
36
|
+
mock_ctx = AsyncMock()
|
|
37
|
+
mock_ctx.__aenter__ = AsyncMock(return_value=mock_response)
|
|
38
|
+
mock_ctx.__aexit__ = AsyncMock(return_value=False)
|
|
39
|
+
mock_session.request = MagicMock(return_value=mock_ctx)
|
|
40
|
+
|
|
41
|
+
with patch("aiohttp.ClientSession", return_value=mock_session):
|
|
42
|
+
result = await client.post("/actions", data={"name": "test"})
|
|
43
|
+
|
|
44
|
+
assert result == {"id": "123"}
|
|
45
|
+
|
|
46
|
+
async def test_post_raises_on_http_error(self):
|
|
47
|
+
client = HttpClient(base_url="https://api.zamp.test")
|
|
48
|
+
mock_response = AsyncMock()
|
|
49
|
+
mock_response.ok = False
|
|
50
|
+
mock_response.status = 500
|
|
51
|
+
mock_response.text = AsyncMock(return_value="Internal Server Error")
|
|
52
|
+
|
|
53
|
+
mock_session = AsyncMock()
|
|
54
|
+
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
|
|
55
|
+
mock_session.__aexit__ = AsyncMock(return_value=False)
|
|
56
|
+
|
|
57
|
+
mock_ctx = AsyncMock()
|
|
58
|
+
mock_ctx.__aenter__ = AsyncMock(return_value=mock_response)
|
|
59
|
+
mock_ctx.__aexit__ = AsyncMock(return_value=False)
|
|
60
|
+
mock_session.request = MagicMock(return_value=mock_ctx)
|
|
61
|
+
|
|
62
|
+
with (
|
|
63
|
+
patch("aiohttp.ClientSession", return_value=mock_session),
|
|
64
|
+
pytest.raises(HttpClientError, match="HTTP 500"),
|
|
65
|
+
):
|
|
66
|
+
await client.post("/actions", data={"name": "test"})
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class TestHttpClientGet:
|
|
70
|
+
async def test_get_without_body(self):
|
|
71
|
+
client = HttpClient(base_url="https://api.zamp.test")
|
|
72
|
+
mock_response = AsyncMock()
|
|
73
|
+
mock_response.ok = True
|
|
74
|
+
mock_response.status = 200
|
|
75
|
+
mock_response.text = AsyncMock(return_value=json.dumps({"status": "COMPLETED"}))
|
|
76
|
+
|
|
77
|
+
mock_session = AsyncMock()
|
|
78
|
+
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
|
|
79
|
+
mock_session.__aexit__ = AsyncMock(return_value=False)
|
|
80
|
+
|
|
81
|
+
mock_ctx = AsyncMock()
|
|
82
|
+
mock_ctx.__aenter__ = AsyncMock(return_value=mock_response)
|
|
83
|
+
mock_ctx.__aexit__ = AsyncMock(return_value=False)
|
|
84
|
+
mock_session.request = MagicMock(return_value=mock_ctx)
|
|
85
|
+
|
|
86
|
+
with patch("aiohttp.ClientSession", return_value=mock_session):
|
|
87
|
+
result = await client.get("/actions/123")
|
|
88
|
+
|
|
89
|
+
assert result == {"status": "COMPLETED"}
|
|
90
|
+
call_kwargs = mock_session.request.call_args.kwargs
|
|
91
|
+
assert call_kwargs["data"] is None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class TestHttpClientEnvelopeUnwrapping:
|
|
95
|
+
async def test_unwraps_data_envelope(self):
|
|
96
|
+
client = HttpClient(base_url="https://api.zamp.test")
|
|
97
|
+
envelope = {"data": {"payload": "inner"}, "error": None}
|
|
98
|
+
mock_response = AsyncMock()
|
|
99
|
+
mock_response.ok = True
|
|
100
|
+
mock_response.status = 200
|
|
101
|
+
mock_response.text = AsyncMock(return_value=json.dumps(envelope))
|
|
102
|
+
|
|
103
|
+
mock_session = AsyncMock()
|
|
104
|
+
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
|
|
105
|
+
mock_session.__aexit__ = AsyncMock(return_value=False)
|
|
106
|
+
|
|
107
|
+
mock_ctx = AsyncMock()
|
|
108
|
+
mock_ctx.__aenter__ = AsyncMock(return_value=mock_response)
|
|
109
|
+
mock_ctx.__aexit__ = AsyncMock(return_value=False)
|
|
110
|
+
mock_session.request = MagicMock(return_value=mock_ctx)
|
|
111
|
+
|
|
112
|
+
with patch("aiohttp.ClientSession", return_value=mock_session):
|
|
113
|
+
result = await client.get("/test")
|
|
114
|
+
|
|
115
|
+
assert result == {"payload": {"payload": "inner"}}
|
|
116
|
+
|
|
117
|
+
async def test_raises_on_error_envelope(self):
|
|
118
|
+
client = HttpClient(base_url="https://api.zamp.test")
|
|
119
|
+
envelope = {"data": None, "error": {"message": "Not found"}}
|
|
120
|
+
mock_response = AsyncMock()
|
|
121
|
+
mock_response.ok = True
|
|
122
|
+
mock_response.status = 200
|
|
123
|
+
mock_response.text = AsyncMock(return_value=json.dumps(envelope))
|
|
124
|
+
|
|
125
|
+
mock_session = AsyncMock()
|
|
126
|
+
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
|
|
127
|
+
mock_session.__aexit__ = AsyncMock(return_value=False)
|
|
128
|
+
|
|
129
|
+
mock_ctx = AsyncMock()
|
|
130
|
+
mock_ctx.__aenter__ = AsyncMock(return_value=mock_response)
|
|
131
|
+
mock_ctx.__aexit__ = AsyncMock(return_value=False)
|
|
132
|
+
mock_session.request = MagicMock(return_value=mock_ctx)
|
|
133
|
+
|
|
134
|
+
with (
|
|
135
|
+
patch("aiohttp.ClientSession", return_value=mock_session),
|
|
136
|
+
pytest.raises(HttpClientError, match="Not found"),
|
|
137
|
+
):
|
|
138
|
+
await client.get("/test")
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class TestHttpClientErrorHandling:
|
|
142
|
+
async def test_timeout_raises_http_client_error(self):
|
|
143
|
+
client = HttpClient(base_url="https://api.zamp.test", timeout=1)
|
|
144
|
+
|
|
145
|
+
with (
|
|
146
|
+
patch("aiohttp.ClientSession", side_effect=TimeoutError("timed out")),
|
|
147
|
+
pytest.raises(HttpClientError, match="Request timed out"),
|
|
148
|
+
):
|
|
149
|
+
await client.get("/test")
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from datetime import timedelta
|
|
2
|
+
|
|
3
|
+
from zamp_sdk.action_executor.models import (
|
|
4
|
+
DEFAULT_RETRY_BACKOFF_COEFFICIENT,
|
|
5
|
+
DEFAULT_RETRY_INITIAL_INTERVAL,
|
|
6
|
+
DEFAULT_RETRY_MAXIMUM_ATTEMPTS,
|
|
7
|
+
DEFAULT_RETRY_MAXIMUM_INTERVAL,
|
|
8
|
+
RetryPolicy,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class TestRetryPolicy:
|
|
13
|
+
def test_fields(self):
|
|
14
|
+
rp = RetryPolicy(
|
|
15
|
+
initial_interval=timedelta(seconds=10),
|
|
16
|
+
maximum_attempts=5,
|
|
17
|
+
maximum_interval=timedelta(minutes=1),
|
|
18
|
+
backoff_coefficient=2.0,
|
|
19
|
+
)
|
|
20
|
+
assert rp.initial_interval == timedelta(seconds=10)
|
|
21
|
+
assert rp.maximum_attempts == 5
|
|
22
|
+
assert rp.maximum_interval == timedelta(minutes=1)
|
|
23
|
+
assert rp.backoff_coefficient == 2.0
|
|
24
|
+
|
|
25
|
+
def test_default_values(self):
|
|
26
|
+
rp = RetryPolicy.default()
|
|
27
|
+
assert rp.initial_interval == DEFAULT_RETRY_INITIAL_INTERVAL
|
|
28
|
+
assert rp.maximum_attempts == DEFAULT_RETRY_MAXIMUM_ATTEMPTS
|
|
29
|
+
assert rp.maximum_interval == DEFAULT_RETRY_MAXIMUM_INTERVAL
|
|
30
|
+
assert rp.backoff_coefficient == DEFAULT_RETRY_BACKOFF_COEFFICIENT
|
|
31
|
+
|
|
32
|
+
def test_serialization(self):
|
|
33
|
+
rp = RetryPolicy.default()
|
|
34
|
+
data = rp.model_dump(mode="json")
|
|
35
|
+
assert isinstance(data, dict)
|
|
36
|
+
assert "initial_interval" in data
|
|
37
|
+
assert "maximum_attempts" in data
|
|
38
|
+
assert "maximum_interval" in data
|
|
39
|
+
assert "backoff_coefficient" in data
|
|
40
|
+
assert data["maximum_attempts"] == DEFAULT_RETRY_MAXIMUM_ATTEMPTS
|
|
41
|
+
assert data["backoff_coefficient"] == DEFAULT_RETRY_BACKOFF_COEFFICIENT
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import json
|
|
3
|
+
from typing import Any, Dict, NoReturn, Optional, Union
|
|
4
|
+
|
|
5
|
+
import aiohttp
|
|
6
|
+
import structlog
|
|
7
|
+
from pydantic import BaseModel
|
|
8
|
+
|
|
9
|
+
logger = structlog.get_logger(__name__)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class HttpClientError(Exception):
|
|
13
|
+
def __init__(
|
|
14
|
+
self,
|
|
15
|
+
message: str,
|
|
16
|
+
status_code: Optional[int] = None,
|
|
17
|
+
response_body: Optional[str] = None,
|
|
18
|
+
):
|
|
19
|
+
self.message = message
|
|
20
|
+
self.status_code = status_code
|
|
21
|
+
self.response_body = response_body
|
|
22
|
+
super().__init__(self.message)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class HttpClient:
|
|
26
|
+
"""Lightweight async HTTP client for JSON API calls."""
|
|
27
|
+
|
|
28
|
+
def __init__(
|
|
29
|
+
self,
|
|
30
|
+
base_url: Optional[str] = None,
|
|
31
|
+
default_headers: Optional[Dict[str, str]] = None,
|
|
32
|
+
timeout: int = 30,
|
|
33
|
+
):
|
|
34
|
+
self.base_url = base_url
|
|
35
|
+
self.default_headers = default_headers or {}
|
|
36
|
+
self.timeout = timeout
|
|
37
|
+
|
|
38
|
+
def _build_url(self, endpoint: str) -> str:
|
|
39
|
+
if self.base_url:
|
|
40
|
+
return f"{self.base_url.rstrip('/')}/{endpoint.lstrip('/')}"
|
|
41
|
+
return endpoint
|
|
42
|
+
|
|
43
|
+
def _handle_request_error(self, exc: Exception) -> NoReturn:
|
|
44
|
+
if isinstance(exc, HttpClientError):
|
|
45
|
+
raise exc
|
|
46
|
+
elif isinstance(exc, asyncio.TimeoutError):
|
|
47
|
+
raise HttpClientError(f"Request timed out: {exc}")
|
|
48
|
+
elif isinstance(exc, aiohttp.ClientError):
|
|
49
|
+
raise HttpClientError(f"Network error: {exc}")
|
|
50
|
+
else:
|
|
51
|
+
raise HttpClientError(f"Unexpected error: {exc}")
|
|
52
|
+
|
|
53
|
+
async def _request(
|
|
54
|
+
self,
|
|
55
|
+
method: str,
|
|
56
|
+
endpoint: str,
|
|
57
|
+
*,
|
|
58
|
+
data: Optional[Union[Dict[str, Any], BaseModel]] = None,
|
|
59
|
+
headers: Optional[Dict[str, str]] = None,
|
|
60
|
+
timeout: Optional[int] = None,
|
|
61
|
+
) -> dict:
|
|
62
|
+
try:
|
|
63
|
+
request_headers = {**self.default_headers}
|
|
64
|
+
if headers:
|
|
65
|
+
request_headers.update(headers)
|
|
66
|
+
|
|
67
|
+
url = self._build_url(endpoint)
|
|
68
|
+
|
|
69
|
+
request_data = None
|
|
70
|
+
if data is not None:
|
|
71
|
+
if isinstance(data, BaseModel):
|
|
72
|
+
request_data = data.model_dump_json()
|
|
73
|
+
else:
|
|
74
|
+
request_data = json.dumps(data)
|
|
75
|
+
request_headers.setdefault("Content-Type", "application/json")
|
|
76
|
+
|
|
77
|
+
client_timeout = aiohttp.ClientTimeout(total=timeout or self.timeout)
|
|
78
|
+
|
|
79
|
+
logger.info("API request", method=method, url=url)
|
|
80
|
+
|
|
81
|
+
async with aiohttp.ClientSession(timeout=client_timeout) as session:
|
|
82
|
+
async with session.request(
|
|
83
|
+
method=method,
|
|
84
|
+
url=url,
|
|
85
|
+
headers=request_headers,
|
|
86
|
+
data=request_data,
|
|
87
|
+
) as response:
|
|
88
|
+
response_text = await response.text()
|
|
89
|
+
|
|
90
|
+
if not response.ok:
|
|
91
|
+
raise HttpClientError(
|
|
92
|
+
f"HTTP {response.status} from {url}",
|
|
93
|
+
status_code=response.status,
|
|
94
|
+
response_body=response_text,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
parsed: dict = json.loads(response_text)
|
|
98
|
+
if isinstance(parsed, dict) and parsed.get("error") is not None:
|
|
99
|
+
err = parsed["error"]
|
|
100
|
+
msg = err.get("message", str(err)) if isinstance(err, dict) else str(err)
|
|
101
|
+
raise HttpClientError(
|
|
102
|
+
msg,
|
|
103
|
+
status_code=response.status,
|
|
104
|
+
response_body=response_text,
|
|
105
|
+
)
|
|
106
|
+
if isinstance(parsed, dict) and "data" in parsed and parsed.get("error") is None:
|
|
107
|
+
return {"payload": parsed["data"]}
|
|
108
|
+
return parsed
|
|
109
|
+
|
|
110
|
+
except Exception as e:
|
|
111
|
+
self._handle_request_error(e)
|
|
112
|
+
|
|
113
|
+
async def post(
|
|
114
|
+
self,
|
|
115
|
+
endpoint: str,
|
|
116
|
+
*,
|
|
117
|
+
data: Optional[Union[Dict[str, Any], BaseModel]] = None,
|
|
118
|
+
headers: Optional[Dict[str, str]] = None,
|
|
119
|
+
timeout: Optional[int] = None,
|
|
120
|
+
) -> dict:
|
|
121
|
+
return await self._request("POST", endpoint, data=data, headers=headers, timeout=timeout)
|
|
122
|
+
|
|
123
|
+
async def get(
|
|
124
|
+
self,
|
|
125
|
+
endpoint: str,
|
|
126
|
+
*,
|
|
127
|
+
headers: Optional[Dict[str, str]] = None,
|
|
128
|
+
timeout: Optional[int] = None,
|
|
129
|
+
) -> dict:
|
|
130
|
+
return await self._request("GET", endpoint, headers=headers, timeout=timeout)
|
|
File without changes
|