midwater 0.1.0__py3-none-any.whl

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.
midwater/__init__.py ADDED
@@ -0,0 +1,72 @@
1
+ """Python SDK for Midwater: send AI-agent conversations, read check results and agent health,
2
+ and verify webhooks."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from . import types, webhooks
7
+ from ._client import AsyncMidwater, Midwater
8
+ from ._errors import (
9
+ APIConnectionError,
10
+ APIError,
11
+ AuthenticationError,
12
+ IdempotencyConflictError,
13
+ MidwaterError,
14
+ NotFoundError,
15
+ PayloadTooLargeError,
16
+ PermissionDeniedError,
17
+ RateLimitError,
18
+ RequestTimeoutError,
19
+ ServerError,
20
+ ServiceUnavailableError,
21
+ ValidationError,
22
+ WaitTimeoutError,
23
+ WebhookVerificationError,
24
+ )
25
+ from ._version import __version__
26
+ from .types import (
27
+ AgentHealth,
28
+ CheckResult,
29
+ Conversation,
30
+ ConversationAccepted,
31
+ ConversationCreate,
32
+ Feedback,
33
+ FeedbackCreate,
34
+ GroupHealth,
35
+ HealthWindow,
36
+ WebhookEvent,
37
+ )
38
+ from .webhooks import verify as verify_webhook
39
+
40
+ __all__ = [
41
+ "__version__",
42
+ "Midwater",
43
+ "AsyncMidwater",
44
+ "MidwaterError",
45
+ "APIError",
46
+ "AuthenticationError",
47
+ "PermissionDeniedError",
48
+ "ValidationError",
49
+ "NotFoundError",
50
+ "RequestTimeoutError",
51
+ "IdempotencyConflictError",
52
+ "PayloadTooLargeError",
53
+ "RateLimitError",
54
+ "ServerError",
55
+ "ServiceUnavailableError",
56
+ "APIConnectionError",
57
+ "WaitTimeoutError",
58
+ "WebhookVerificationError",
59
+ "webhooks",
60
+ "verify_webhook",
61
+ "types",
62
+ "AgentHealth",
63
+ "CheckResult",
64
+ "Conversation",
65
+ "ConversationAccepted",
66
+ "ConversationCreate",
67
+ "Feedback",
68
+ "FeedbackCreate",
69
+ "GroupHealth",
70
+ "HealthWindow",
71
+ "WebhookEvent",
72
+ ]
midwater/_base.py ADDED
@@ -0,0 +1,242 @@
1
+ """Configuration, request building, error mapping and retry policy shared by both clients."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import random
8
+ import re
9
+ from collections.abc import Mapping
10
+ from typing import Any, Dict, List, Optional, Type
11
+ from urllib.parse import quote
12
+
13
+ import httpx
14
+
15
+ from ._errors import (
16
+ APIError,
17
+ AuthenticationError,
18
+ IdempotencyConflictError,
19
+ MidwaterError,
20
+ NotFoundError,
21
+ PayloadTooLargeError,
22
+ PermissionDeniedError,
23
+ RateLimitError,
24
+ RequestTimeoutError,
25
+ ServerError,
26
+ ServiceUnavailableError,
27
+ ValidationError,
28
+ )
29
+ from ._version import __version__
30
+
31
+ DEFAULT_TIMEOUT = 30.0
32
+ DEFAULT_MAX_RETRIES = 2
33
+ # Retry at most 3 times, whatever the caller asks for.
34
+ MAX_RETRIES_CAP = 3
35
+
36
+ API_KEY_ENV = "MIDWATER_API_KEY"
37
+ BASE_URL_ENV = "MIDWATER_BASE_URL"
38
+
39
+ USER_AGENT = f"midwater-python/{__version__}"
40
+
41
+ # Test keys start mw_test_, live keys mw_live_. Keys made before October 2026 start vk_.
42
+ _API_KEY_PATTERN = re.compile(r"^(mw|vk)_(test|live)_")
43
+
44
+ REQUEST_ID_HEADER = "Midwater-Request-Id"
45
+
46
+ # 408, 429 and every 5xx. Retried only on requests that are safe to repeat.
47
+ RETRY_STATUSES = frozenset({408, 429, *range(500, 600)})
48
+ _BACKOFF_BASE = 0.5
49
+ _BACKOFF_CAP = 8.0
50
+ _RETRY_AFTER_CAP = 60.0
51
+
52
+ TERMINAL_STATUSES = frozenset({"done", "failed"})
53
+
54
+
55
+ def resolve_api_key(api_key: Optional[str]) -> str:
56
+ key = api_key if api_key is not None else os.environ.get(API_KEY_ENV)
57
+ if not key:
58
+ raise AuthenticationError(f"No API key found. Set {API_KEY_ENV} or pass api_key.")
59
+ if not _API_KEY_PATTERN.match(key):
60
+ # Never echo the key itself.
61
+ raise AuthenticationError(
62
+ "The API key is not a Midwater key: it should start with mw_test_ or mw_live_ "
63
+ "(older keys: vk_test_ or vk_live_)."
64
+ )
65
+ return key
66
+
67
+
68
+ def resolve_base_url(base_url: Optional[str]) -> str:
69
+ """There is no default host: the base URL comes from the argument or the environment."""
70
+ url = base_url or os.environ.get(BASE_URL_ENV)
71
+ if not url:
72
+ raise MidwaterError(
73
+ f"Set {BASE_URL_ENV} or pass base_url: the base URL for your Midwater environment"
74
+ )
75
+ return url.rstrip("/")
76
+
77
+
78
+ def validate_max_retries(max_retries: int) -> int:
79
+ """Reject negatives; clamp anything above ``MAX_RETRIES_CAP`` to the cap."""
80
+ if not isinstance(max_retries, int) or isinstance(max_retries, bool) or max_retries < 0:
81
+ raise ValueError("max_retries must be a non-negative integer")
82
+ return min(max_retries, MAX_RETRIES_CAP)
83
+
84
+
85
+ def path_segment(value: str) -> str:
86
+ """URL-encode one path segment (IDs may be your own external_id)."""
87
+ if not isinstance(value, str) or value == "":
88
+ raise ValueError("id must be a non-empty string")
89
+ return quote(value, safe="")
90
+
91
+
92
+ def encode_json(body: Any) -> bytes:
93
+ return json.dumps(body, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
94
+
95
+
96
+ def build_headers(
97
+ api_key: str, *, has_body: bool, idempotency_key: Optional[str]
98
+ ) -> Dict[str, str]:
99
+ headers = {
100
+ "Authorization": f"Bearer {api_key}",
101
+ "Accept": "application/json",
102
+ "User-Agent": USER_AGENT,
103
+ }
104
+ if has_body:
105
+ headers["Content-Type"] = "application/json"
106
+ if idempotency_key is not None:
107
+ headers["Idempotency-Key"] = idempotency_key
108
+ return headers
109
+
110
+
111
+ def should_retry_status(status: int, *, retryable: bool) -> bool:
112
+ """408, 429 and 5xx are retried, and only for requests that are safe to repeat."""
113
+ return retryable and status in RETRY_STATUSES
114
+
115
+
116
+ def retry_delay(attempt: int, headers: Optional[httpx.Headers] = None) -> float:
117
+ """Seconds to wait before retry number ``attempt + 1``.
118
+
119
+ A numeric ``Retry-After`` header wins when present. Midwater doesn't send one today; this
120
+ keeps the SDK correct if it starts to (or a proxy in front of it does).
121
+ """
122
+ if headers is not None:
123
+ retry_after = headers.get("retry-after")
124
+ if retry_after is not None:
125
+ try:
126
+ seconds = float(retry_after.strip())
127
+ except ValueError:
128
+ seconds = -1.0
129
+ if seconds >= 0:
130
+ return min(seconds, _RETRY_AFTER_CAP)
131
+ base: float = _BACKOFF_BASE * (2.0**attempt)
132
+ jittered = base * (1.0 + 0.25 * random.random())
133
+ return min(jittered, _BACKOFF_CAP)
134
+
135
+
136
+ def header_request_id(response: httpx.Response) -> Optional[str]:
137
+ """The ``Midwater-Request-Id`` response header (planned server-side; ``None`` until then)."""
138
+ value = response.headers.get(REQUEST_ID_HEADER)
139
+ if not isinstance(value, str) or not value.strip():
140
+ return None
141
+ return value.strip()
142
+
143
+
144
+ def is_replayed(response: httpx.Response) -> bool:
145
+ value = str(response.headers.get("idempotent-replayed", ""))
146
+ return value.strip().lower() == "true"
147
+
148
+
149
+ def parse_json(response: httpx.Response) -> Dict[str, Any]:
150
+ try:
151
+ data = response.json()
152
+ except ValueError as exc:
153
+ raise APIError(
154
+ f"Midwater answered HTTP {response.status_code} with a body that isn't JSON",
155
+ status=response.status_code,
156
+ body=response.text,
157
+ ) from exc
158
+ if not isinstance(data, dict):
159
+ raise APIError(
160
+ f"Midwater answered HTTP {response.status_code} with JSON that isn't an object",
161
+ status=response.status_code,
162
+ body=data,
163
+ )
164
+ return data
165
+
166
+
167
+ _STATUS_ERRORS: Dict[int, Type[APIError]] = {
168
+ 400: ValidationError,
169
+ 401: AuthenticationError,
170
+ 403: PermissionDeniedError,
171
+ 404: NotFoundError,
172
+ 408: RequestTimeoutError,
173
+ 409: IdempotencyConflictError,
174
+ 413: PayloadTooLargeError,
175
+ 422: ValidationError,
176
+ 429: RateLimitError,
177
+ 503: ServiceUnavailableError,
178
+ }
179
+
180
+
181
+ def _error_class(status: int) -> Type[APIError]:
182
+ """Pick the exception class from the HTTP status alone, never from the error type."""
183
+ cls = _STATUS_ERRORS.get(status)
184
+ if cls is not None:
185
+ return cls
186
+ if status >= 500:
187
+ return ServerError
188
+ return APIError
189
+
190
+
191
+ def _normalise_fields(raw: Any) -> Dict[str, List[str]]:
192
+ if not isinstance(raw, Mapping):
193
+ return {}
194
+ out: Dict[str, List[str]] = {}
195
+ for key, value in raw.items():
196
+ if isinstance(value, list):
197
+ out[str(key)] = [str(v) for v in value]
198
+ elif value is not None:
199
+ out[str(key)] = [str(value)]
200
+ return out
201
+
202
+
203
+ def error_from_response(response: httpx.Response) -> APIError:
204
+ status = response.status_code
205
+ body: Any
206
+ try:
207
+ body = response.json()
208
+ except ValueError:
209
+ body = response.text
210
+
211
+ error_type: Optional[str] = None
212
+ message: Optional[str] = None
213
+ request_id: Optional[str] = None
214
+ fields: Dict[str, List[str]] = {}
215
+ if isinstance(body, dict) and isinstance(body.get("error"), dict):
216
+ err = body["error"]
217
+ if isinstance(err.get("type"), str):
218
+ error_type = err["type"]
219
+ if isinstance(err.get("message"), str):
220
+ message = err["message"]
221
+ if isinstance(err.get("request_id"), str) and err["request_id"]:
222
+ request_id = err["request_id"]
223
+ fields = _normalise_fields(err.get("fields"))
224
+ if request_id is None:
225
+ request_id = header_request_id(response)
226
+
227
+ if not message:
228
+ reason = response.reason_phrase or "error"
229
+ message = f"Midwater answered HTTP {status} {reason}".rstrip()
230
+ if isinstance(body, str) and body.strip():
231
+ snippet = body.strip()
232
+ message += f": {snippet[:200]}"
233
+
234
+ cls = _error_class(status)
235
+ return cls(
236
+ message,
237
+ status=status,
238
+ type=error_type,
239
+ fields=fields,
240
+ body=body,
241
+ request_id=request_id,
242
+ )