clawops 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.
clawops/__init__.py ADDED
@@ -0,0 +1,62 @@
1
+ """ClawOps Voice API의 공식 Python SDK.
2
+
3
+ Example::
4
+
5
+ from clawops import ClawOps
6
+
7
+ client = ClawOps(api_key="sk_...", account_id="AC1a2b3c4d")
8
+ call = client.calls.create(
9
+ to="01012345678", from_="07052358010",
10
+ url="https://my-app.com/twiml",
11
+ )
12
+ print(call.call_id)
13
+ """
14
+
15
+ from ._client import AsyncClawOps, ClawOps
16
+ from ._exceptions import (
17
+ APIConnectionError,
18
+ APIError,
19
+ APIResponseValidationError,
20
+ APIStatusError,
21
+ APITimeoutError,
22
+ AuthenticationError,
23
+ BadRequestError,
24
+ ClawOpsError,
25
+ ConflictError,
26
+ InternalServerError,
27
+ NotFoundError,
28
+ PermissionDeniedError,
29
+ ServiceUnavailableError,
30
+ UnprocessableEntityError,
31
+ )
32
+ from ._version import __version__
33
+ from .webhooks import WebhookVerificationError
34
+
35
+ __all__ = [
36
+ "ClawOps",
37
+ "AsyncClawOps",
38
+ "ClawOpsError",
39
+ "APIError",
40
+ "APIStatusError",
41
+ "APIConnectionError",
42
+ "APITimeoutError",
43
+ "APIResponseValidationError",
44
+ "BadRequestError",
45
+ "AuthenticationError",
46
+ "PermissionDeniedError",
47
+ "NotFoundError",
48
+ "ConflictError",
49
+ "UnprocessableEntityError",
50
+ "InternalServerError",
51
+ "ServiceUnavailableError",
52
+ "WebhookVerificationError",
53
+ "__version__",
54
+ ]
55
+
56
+ _locals = locals()
57
+ for _name in __all__:
58
+ if not _name.startswith("__"):
59
+ try:
60
+ _locals[_name].__module__ = "clawops"
61
+ except (TypeError, AttributeError):
62
+ pass
@@ -0,0 +1,342 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ from random import random
5
+ from typing import Any, TypeVar
6
+
7
+ import httpx
8
+ import pydantic
9
+
10
+ from ._constants import (
11
+ DEFAULT_BASE_URL,
12
+ DEFAULT_CONNECTION_LIMITS,
13
+ DEFAULT_MAX_RETRIES,
14
+ DEFAULT_TIMEOUT,
15
+ INITIAL_RETRY_DELAY,
16
+ MAX_RETRY_DELAY,
17
+ )
18
+ from ._exceptions import (
19
+ APIConnectionError,
20
+ APIResponseValidationError,
21
+ APITimeoutError,
22
+ _make_status_error,
23
+ )
24
+ from ._version import __version__
25
+
26
+ _T = TypeVar("_T", bound=pydantic.BaseModel)
27
+
28
+
29
+ class SyncAPIClient:
30
+ """동기 HTTP 클라이언트 베이스. httpx.Client를 래핑하며 인증, 재시도, 타임아웃, 에러 매핑을 처리합니다."""
31
+
32
+ _client: httpx.Client
33
+ _api_key: str
34
+ _base_url: str
35
+ _max_retries: int
36
+ _timeout: httpx.Timeout
37
+
38
+ def __init__(
39
+ self,
40
+ *,
41
+ api_key: str,
42
+ base_url: str = DEFAULT_BASE_URL,
43
+ timeout: float | httpx.Timeout = DEFAULT_TIMEOUT,
44
+ max_retries: int = DEFAULT_MAX_RETRIES,
45
+ http_client: httpx.Client | None = None,
46
+ default_headers: dict[str, str] | None = None,
47
+ ) -> None:
48
+ self._api_key = api_key
49
+ self._base_url = base_url.rstrip("/")
50
+ self._max_retries = max_retries
51
+
52
+ if isinstance(timeout, (int, float)):
53
+ self._timeout = httpx.Timeout(timeout)
54
+ else:
55
+ self._timeout = timeout
56
+
57
+ if http_client is not None:
58
+ self._client = http_client
59
+ else:
60
+ self._client = httpx.Client(
61
+ base_url=self._base_url,
62
+ timeout=self._timeout,
63
+ limits=DEFAULT_CONNECTION_LIMITS,
64
+ )
65
+
66
+ def _build_headers(self, extra_headers: dict[str, str] | None = None) -> dict[str, str]:
67
+ headers = {
68
+ "Authorization": f"Bearer {self._api_key}",
69
+ "Content-Type": "application/json",
70
+ "Accept": "application/json",
71
+ "User-Agent": f"clawops-python/{__version__}",
72
+ }
73
+ if extra_headers:
74
+ headers.update(extra_headers)
75
+ return headers
76
+
77
+ def _request(
78
+ self,
79
+ method: str,
80
+ path: str,
81
+ *,
82
+ body: dict[str, Any] | None = None,
83
+ query: dict[str, Any] | None = None,
84
+ cast_to: type[_T] | None = None,
85
+ extra_headers: dict[str, str] | None = None,
86
+ extra_query: dict[str, object] | None = None,
87
+ timeout: float | httpx.Timeout | None = None,
88
+ ) -> _T | None:
89
+ headers = self._build_headers(extra_headers)
90
+
91
+ params = query.copy() if query else {}
92
+ if extra_query:
93
+ params.update(extra_query)
94
+
95
+ req_timeout = timeout if timeout is not None else self._timeout
96
+ if isinstance(req_timeout, (int, float)):
97
+ req_timeout = httpx.Timeout(req_timeout)
98
+
99
+ retries_left = self._max_retries
100
+
101
+ while True:
102
+ try:
103
+ response = self._client.request(
104
+ method=method,
105
+ url=path,
106
+ json=body,
107
+ params=params if params else None,
108
+ headers=headers,
109
+ timeout=req_timeout,
110
+ )
111
+ except httpx.TimeoutException as e:
112
+ if retries_left > 0:
113
+ retries_left -= 1
114
+ time.sleep(self._retry_delay(self._max_retries - retries_left))
115
+ continue
116
+ raise APITimeoutError(request=httpx.Request(method, self._base_url + path)) from e
117
+ except httpx.ConnectError as e:
118
+ if retries_left > 0:
119
+ retries_left -= 1
120
+ time.sleep(self._retry_delay(self._max_retries - retries_left))
121
+ continue
122
+ raise APIConnectionError(request=httpx.Request(method, self._base_url + path)) from e
123
+
124
+ if response.is_success:
125
+ if response.status_code == 204 or cast_to is None:
126
+ return None
127
+ try:
128
+ return cast_to.model_validate(response.json())
129
+ except pydantic.ValidationError as e:
130
+ raise APIResponseValidationError(response=response) from e
131
+
132
+ if retries_left > 0 and self._should_retry(response):
133
+ retries_left -= 1
134
+ time.sleep(self._retry_delay(self._max_retries - retries_left))
135
+ continue
136
+
137
+ raise _make_status_error(response=response)
138
+
139
+ def _should_retry(self, response: httpx.Response) -> bool:
140
+ if response.status_code in (408, 409, 429):
141
+ return True
142
+ if response.status_code >= 500:
143
+ return True
144
+ return False
145
+
146
+ def _retry_delay(self, retries_taken: int) -> float:
147
+ delay = min(INITIAL_RETRY_DELAY * (2 ** retries_taken), MAX_RETRY_DELAY)
148
+ return delay * (1 + random())
149
+
150
+ def _get(self, path: str, *, cast_to: type[_T], query: dict[str, Any] | None = None,
151
+ extra_headers: dict[str, str] | None = None, extra_query: dict[str, object] | None = None,
152
+ timeout: float | httpx.Timeout | None = None) -> _T:
153
+ result = self._request("GET", path, cast_to=cast_to, query=query,
154
+ extra_headers=extra_headers, extra_query=extra_query, timeout=timeout)
155
+ assert result is not None
156
+ return result
157
+
158
+ def _post(self, path: str, *, body: dict[str, Any] | None = None, cast_to: type[_T],
159
+ extra_headers: dict[str, str] | None = None, extra_query: dict[str, object] | None = None,
160
+ timeout: float | httpx.Timeout | None = None) -> _T:
161
+ result = self._request("POST", path, body=body, cast_to=cast_to,
162
+ extra_headers=extra_headers, extra_query=extra_query, timeout=timeout)
163
+ assert result is not None
164
+ return result
165
+
166
+ def _put(self, path: str, *, body: dict[str, Any] | None = None, cast_to: type[_T],
167
+ extra_headers: dict[str, str] | None = None, extra_query: dict[str, object] | None = None,
168
+ timeout: float | httpx.Timeout | None = None) -> _T:
169
+ result = self._request("PUT", path, body=body, cast_to=cast_to,
170
+ extra_headers=extra_headers, extra_query=extra_query, timeout=timeout)
171
+ assert result is not None
172
+ return result
173
+
174
+ def _delete(self, path: str, *, extra_headers: dict[str, str] | None = None,
175
+ timeout: float | httpx.Timeout | None = None) -> None:
176
+ self._request("DELETE", path, cast_to=None, extra_headers=extra_headers, timeout=timeout)
177
+
178
+ def close(self) -> None:
179
+ self._client.close()
180
+
181
+ def __enter__(self) -> SyncAPIClient:
182
+ return self
183
+
184
+ def __exit__(self, *args: Any) -> None:
185
+ self.close()
186
+
187
+
188
+ class AsyncAPIClient:
189
+ """비동기 HTTP 클라이언트 베이스. SyncAPIClient의 async 버전."""
190
+
191
+ _client: httpx.AsyncClient
192
+ _api_key: str
193
+ _base_url: str
194
+ _max_retries: int
195
+ _timeout: httpx.Timeout
196
+
197
+ def __init__(
198
+ self,
199
+ *,
200
+ api_key: str,
201
+ base_url: str = DEFAULT_BASE_URL,
202
+ timeout: float | httpx.Timeout = DEFAULT_TIMEOUT,
203
+ max_retries: int = DEFAULT_MAX_RETRIES,
204
+ http_client: httpx.AsyncClient | None = None,
205
+ default_headers: dict[str, str] | None = None,
206
+ ) -> None:
207
+ self._api_key = api_key
208
+ self._base_url = base_url.rstrip("/")
209
+ self._max_retries = max_retries
210
+
211
+ if isinstance(timeout, (int, float)):
212
+ self._timeout = httpx.Timeout(timeout)
213
+ else:
214
+ self._timeout = timeout
215
+
216
+ if http_client is not None:
217
+ self._client = http_client
218
+ else:
219
+ self._client = httpx.AsyncClient(
220
+ base_url=self._base_url,
221
+ timeout=self._timeout,
222
+ limits=DEFAULT_CONNECTION_LIMITS,
223
+ )
224
+
225
+ def _build_headers(self, extra_headers: dict[str, str] | None = None) -> dict[str, str]:
226
+ headers = {
227
+ "Authorization": f"Bearer {self._api_key}",
228
+ "Content-Type": "application/json",
229
+ "Accept": "application/json",
230
+ "User-Agent": f"clawops-python/{__version__}",
231
+ }
232
+ if extra_headers:
233
+ headers.update(extra_headers)
234
+ return headers
235
+
236
+ async def _request(
237
+ self,
238
+ method: str,
239
+ path: str,
240
+ *,
241
+ body: dict[str, Any] | None = None,
242
+ query: dict[str, Any] | None = None,
243
+ cast_to: type[_T] | None = None,
244
+ extra_headers: dict[str, str] | None = None,
245
+ extra_query: dict[str, object] | None = None,
246
+ timeout: float | httpx.Timeout | None = None,
247
+ ) -> _T | None:
248
+ import asyncio
249
+
250
+ headers = self._build_headers(extra_headers)
251
+ params = query.copy() if query else {}
252
+ if extra_query:
253
+ params.update(extra_query)
254
+
255
+ req_timeout = timeout if timeout is not None else self._timeout
256
+ if isinstance(req_timeout, (int, float)):
257
+ req_timeout = httpx.Timeout(req_timeout)
258
+
259
+ retries_left = self._max_retries
260
+
261
+ while True:
262
+ try:
263
+ response = await self._client.request(
264
+ method=method, url=path, json=body,
265
+ params=params if params else None,
266
+ headers=headers, timeout=req_timeout,
267
+ )
268
+ except httpx.TimeoutException as e:
269
+ if retries_left > 0:
270
+ retries_left -= 1
271
+ await asyncio.sleep(self._retry_delay(self._max_retries - retries_left))
272
+ continue
273
+ raise APITimeoutError(request=httpx.Request(method, self._base_url + path)) from e
274
+ except httpx.ConnectError as e:
275
+ if retries_left > 0:
276
+ retries_left -= 1
277
+ await asyncio.sleep(self._retry_delay(self._max_retries - retries_left))
278
+ continue
279
+ raise APIConnectionError(request=httpx.Request(method, self._base_url + path)) from e
280
+
281
+ if response.is_success:
282
+ if response.status_code == 204 or cast_to is None:
283
+ return None
284
+ try:
285
+ return cast_to.model_validate(response.json())
286
+ except pydantic.ValidationError as e:
287
+ raise APIResponseValidationError(response=response) from e
288
+
289
+ if retries_left > 0 and self._should_retry(response):
290
+ retries_left -= 1
291
+ await asyncio.sleep(self._retry_delay(self._max_retries - retries_left))
292
+ continue
293
+
294
+ raise _make_status_error(response=response)
295
+
296
+ def _should_retry(self, response: httpx.Response) -> bool:
297
+ if response.status_code in (408, 409, 429):
298
+ return True
299
+ if response.status_code >= 500:
300
+ return True
301
+ return False
302
+
303
+ def _retry_delay(self, retries_taken: int) -> float:
304
+ delay = min(INITIAL_RETRY_DELAY * (2 ** retries_taken), MAX_RETRY_DELAY)
305
+ return delay * (1 + random())
306
+
307
+ async def _get(self, path: str, *, cast_to: type[_T], query: dict[str, Any] | None = None,
308
+ extra_headers: dict[str, str] | None = None, extra_query: dict[str, object] | None = None,
309
+ timeout: float | httpx.Timeout | None = None) -> _T:
310
+ result = await self._request("GET", path, cast_to=cast_to, query=query,
311
+ extra_headers=extra_headers, extra_query=extra_query, timeout=timeout)
312
+ assert result is not None
313
+ return result
314
+
315
+ async def _post(self, path: str, *, body: dict[str, Any] | None = None, cast_to: type[_T],
316
+ extra_headers: dict[str, str] | None = None, extra_query: dict[str, object] | None = None,
317
+ timeout: float | httpx.Timeout | None = None) -> _T:
318
+ result = await self._request("POST", path, body=body, cast_to=cast_to,
319
+ extra_headers=extra_headers, extra_query=extra_query, timeout=timeout)
320
+ assert result is not None
321
+ return result
322
+
323
+ async def _put(self, path: str, *, body: dict[str, Any] | None = None, cast_to: type[_T],
324
+ extra_headers: dict[str, str] | None = None, extra_query: dict[str, object] | None = None,
325
+ timeout: float | httpx.Timeout | None = None) -> _T:
326
+ result = await self._request("PUT", path, body=body, cast_to=cast_to,
327
+ extra_headers=extra_headers, extra_query=extra_query, timeout=timeout)
328
+ assert result is not None
329
+ return result
330
+
331
+ async def _delete(self, path: str, *, extra_headers: dict[str, str] | None = None,
332
+ timeout: float | httpx.Timeout | None = None) -> None:
333
+ await self._request("DELETE", path, cast_to=None, extra_headers=extra_headers, timeout=timeout)
334
+
335
+ async def close(self) -> None:
336
+ await self._client.aclose()
337
+
338
+ async def __aenter__(self) -> AsyncAPIClient:
339
+ return self
340
+
341
+ async def __aexit__(self, *args: Any) -> None:
342
+ await self.close()
clawops/_client.py ADDED
@@ -0,0 +1,171 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+
5
+ import httpx
6
+
7
+ from ._base_client import AsyncAPIClient, SyncAPIClient
8
+ from ._constants import DEFAULT_BASE_URL, DEFAULT_MAX_RETRIES, DEFAULT_TIMEOUT
9
+ from ._exceptions import ClawOpsError
10
+ from .resources.accounts import AccountContext, AsyncAccountContext
11
+ from .resources.calls import AsyncCalls, Calls
12
+ from .resources.numbers import AsyncNumbers, Numbers
13
+ from .resources.sip import AsyncSip, Sip
14
+ from .webhooks import Webhooks
15
+
16
+
17
+ class ClawOps(SyncAPIClient):
18
+ """ClawOps Voice API의 동기 클라이언트.
19
+
20
+ Example::
21
+
22
+ from clawops import ClawOps
23
+
24
+ client = ClawOps(api_key="sk_...", account_id="AC1a2b3c4d")
25
+
26
+ call = client.calls.create(
27
+ to="01012345678", from_="07052358010",
28
+ url="https://my-app.com/twiml",
29
+ )
30
+
31
+ other = client.accounts("AC_other")
32
+ other.calls.list()
33
+
34
+ Args:
35
+ api_key: API 키 (sk_...). 생략 시 CLAWOPS_API_KEY 환경변수 사용.
36
+ account_id: 기본 계정 ID (AC...). 생략 시 CLAWOPS_ACCOUNT_ID 환경변수 사용.
37
+ base_url: API 기본 URL. 기본값: https://api.claw-ops.com
38
+ timeout: 요청 타임아웃 (초 또는 httpx.Timeout). 기본값: 600초.
39
+ max_retries: 최대 재시도 횟수. 기본값: 2.
40
+ http_client: 커스텀 httpx.Client 인스턴스.
41
+ default_headers: 모든 요청에 포함할 기본 HTTP 헤더.
42
+ """
43
+
44
+ _default_account_id: str
45
+
46
+ def __init__(
47
+ self,
48
+ *,
49
+ api_key: str | None = None,
50
+ account_id: str | None = None,
51
+ base_url: str | None = None,
52
+ timeout: float | httpx.Timeout = DEFAULT_TIMEOUT,
53
+ max_retries: int = DEFAULT_MAX_RETRIES,
54
+ http_client: httpx.Client | None = None,
55
+ default_headers: dict[str, str] | None = None,
56
+ ) -> None:
57
+ if api_key is None:
58
+ api_key = os.environ.get("CLAWOPS_API_KEY")
59
+ if api_key is None:
60
+ raise ClawOpsError("api_key를 지정하거나 CLAWOPS_API_KEY 환경변수를 설정하세요.")
61
+
62
+ if account_id is None:
63
+ account_id = os.environ.get("CLAWOPS_ACCOUNT_ID")
64
+ if account_id is None:
65
+ raise ClawOpsError("account_id를 지정하거나 CLAWOPS_ACCOUNT_ID 환경변수를 설정하세요.")
66
+
67
+ if base_url is None:
68
+ base_url = os.environ.get("CLAWOPS_BASE_URL", DEFAULT_BASE_URL)
69
+
70
+ self._default_account_id = account_id
71
+
72
+ super().__init__(
73
+ api_key=api_key, base_url=base_url, timeout=timeout,
74
+ max_retries=max_retries, http_client=http_client, default_headers=default_headers,
75
+ )
76
+
77
+ @property
78
+ def calls(self) -> Calls:
79
+ """통화(Calls) 리소스에 접근합니다."""
80
+ return Calls(client=self, account_id=self._default_account_id)
81
+
82
+ @property
83
+ def numbers(self) -> Numbers:
84
+ """전화번호(Numbers) 리소스에 접근합니다."""
85
+ return Numbers(client=self, account_id=self._default_account_id)
86
+
87
+ @property
88
+ def sip(self) -> Sip:
89
+ """SIP 리소스에 접근합니다."""
90
+ return Sip(client=self, account_id=self._default_account_id)
91
+
92
+ @property
93
+ def webhooks(self) -> Webhooks:
94
+ """Webhook 서명 검증 유틸리티."""
95
+ return Webhooks()
96
+
97
+ def accounts(self, account_id: str) -> AccountContext:
98
+ """다른 계정의 리소스에 접근합니다.
99
+
100
+ Args:
101
+ account_id: 접근할 계정 ID.
102
+
103
+ Returns:
104
+ 해당 계정에 바인딩된 AccountContext 객체.
105
+ """
106
+ return AccountContext(client=self, account_id=account_id)
107
+
108
+
109
+ class AsyncClawOps(AsyncAPIClient):
110
+ """ClawOps Voice API의 비동기 클라이언트.
111
+
112
+ Example::
113
+
114
+ async with AsyncClawOps(api_key="sk_...", account_id="AC...") as client:
115
+ call = await client.calls.create(
116
+ to="01012345678", from_="07052358010",
117
+ url="https://my-app.com/twiml",
118
+ )
119
+ """
120
+
121
+ _default_account_id: str
122
+
123
+ def __init__(
124
+ self,
125
+ *,
126
+ api_key: str | None = None,
127
+ account_id: str | None = None,
128
+ base_url: str | None = None,
129
+ timeout: float | httpx.Timeout = DEFAULT_TIMEOUT,
130
+ max_retries: int = DEFAULT_MAX_RETRIES,
131
+ http_client: httpx.AsyncClient | None = None,
132
+ default_headers: dict[str, str] | None = None,
133
+ ) -> None:
134
+ if api_key is None:
135
+ api_key = os.environ.get("CLAWOPS_API_KEY")
136
+ if api_key is None:
137
+ raise ClawOpsError("api_key를 지정하거나 CLAWOPS_API_KEY 환경변수를 설정하세요.")
138
+
139
+ if account_id is None:
140
+ account_id = os.environ.get("CLAWOPS_ACCOUNT_ID")
141
+ if account_id is None:
142
+ raise ClawOpsError("account_id를 지정하거나 CLAWOPS_ACCOUNT_ID 환경변수를 설정하세요.")
143
+
144
+ if base_url is None:
145
+ base_url = os.environ.get("CLAWOPS_BASE_URL", DEFAULT_BASE_URL)
146
+
147
+ self._default_account_id = account_id
148
+
149
+ super().__init__(
150
+ api_key=api_key, base_url=base_url, timeout=timeout,
151
+ max_retries=max_retries, http_client=http_client, default_headers=default_headers,
152
+ )
153
+
154
+ @property
155
+ def calls(self) -> AsyncCalls:
156
+ return AsyncCalls(client=self, account_id=self._default_account_id)
157
+
158
+ @property
159
+ def numbers(self) -> AsyncNumbers:
160
+ return AsyncNumbers(client=self, account_id=self._default_account_id)
161
+
162
+ @property
163
+ def sip(self) -> AsyncSip:
164
+ return AsyncSip(client=self, account_id=self._default_account_id)
165
+
166
+ @property
167
+ def webhooks(self) -> Webhooks:
168
+ return Webhooks()
169
+
170
+ def accounts(self, account_id: str) -> AsyncAccountContext:
171
+ return AsyncAccountContext(client=self, account_id=account_id)
clawops/_constants.py ADDED
@@ -0,0 +1,13 @@
1
+ from __future__ import annotations
2
+
3
+ import httpx
4
+
5
+ DEFAULT_TIMEOUT = httpx.Timeout(timeout=600.0, connect=5.0)
6
+ DEFAULT_MAX_RETRIES = 2
7
+ DEFAULT_BASE_URL = "https://api.claw-ops.com"
8
+ INITIAL_RETRY_DELAY = 0.5
9
+ MAX_RETRY_DELAY = 8.0
10
+ DEFAULT_CONNECTION_LIMITS = httpx.Limits(
11
+ max_connections=1000,
12
+ max_keepalive_connections=100,
13
+ )