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 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.
@@ -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
+ [![PyPI version](https://img.shields.io/pypi/v/zamp-sdk.svg)](https://pypi.org/project/zamp-sdk/)
33
+ [![Python](https://img.shields.io/pypi/pyversions/zamp-sdk.svg)](https://pypi.org/project/zamp-sdk/)
34
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](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
+
@@ -0,0 +1,166 @@
1
+ # Zamp SDK
2
+
3
+ [![PyPI version](https://img.shields.io/pypi/v/zamp-sdk.svg)](https://pypi.org/project/zamp-sdk/)
4
+ [![Python](https://img.shields.io/pypi/pyversions/zamp-sdk.svg)](https://pypi.org/project/zamp-sdk/)
5
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](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,4 @@
1
+ from zamp_sdk.action_executor import ActionExecutor
2
+ from zamp_sdk.action_executor.models import RetryPolicy, SdkConfig
3
+
4
+ __all__ = ["ActionExecutor", "RetryPolicy", "SdkConfig"]
@@ -0,0 +1,3 @@
1
+ from zamp_sdk.action_executor.action_executor import ActionExecutor
2
+
3
+ __all__ = ["ActionExecutor"]
@@ -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,3 @@
1
+ POLL_INITIAL_INTERVAL_SECONDS = 1.0
2
+ POLL_MAX_INTERVAL_SECONDS = 30.0
3
+ POLL_TIMEOUT_SECONDS = 600.0
@@ -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
+ )
@@ -0,0 +1,8 @@
1
+ from pydantic import BaseModel
2
+
3
+
4
+ class SdkConfig(BaseModel):
5
+ """Resolved configuration for the Zamp API."""
6
+
7
+ base_url: str
8
+ auth_token: str
@@ -0,0 +1,11 @@
1
+ import pytest
2
+
3
+
4
+ @pytest.fixture
5
+ def base_url():
6
+ return "https://api.zamp.test"
7
+
8
+
9
+ @pytest.fixture
10
+ def auth_token():
11
+ return "test-token-abc123"
@@ -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,6 @@
1
+ from zamp_sdk.action_executor.utils.http_client import HttpClient, HttpClientError
2
+
3
+ __all__ = [
4
+ "HttpClient",
5
+ "HttpClientError",
6
+ ]
@@ -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