threecommon 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.
@@ -0,0 +1,79 @@
1
+ """Official Python client for the 3Common Public API.
2
+
3
+ Top-level entry points:
4
+
5
+ * [ThreeCommon][threecommon.ThreeCommon] — synchronous client
6
+ * [AsyncThreeCommon][threecommon.AsyncThreeCommon] — async client (httpx-backed)
7
+
8
+ Quick start:
9
+
10
+ from threecommon import ThreeCommon
11
+
12
+ client = ThreeCommon(api_key="3co_...")
13
+ result = client.events.list(status="open", page_size=50)
14
+
15
+ for event in client.events.list_auto_paginate(status="open"):
16
+ print(event.name)
17
+
18
+ Async equivalent:
19
+
20
+ import asyncio
21
+ from threecommon import AsyncThreeCommon
22
+
23
+ async def main() -> None:
24
+ async with AsyncThreeCommon(api_key="3co_...") as client:
25
+ async for event in client.events.list_auto_paginate(status="open"):
26
+ print(event.name)
27
+
28
+ asyncio.run(main())
29
+ """
30
+
31
+ from threecommon.api_version import API_PATH, API_VERSION
32
+ from threecommon.client import AsyncThreeCommon, ThreeCommon
33
+ from threecommon.config import (
34
+ DEFAULT_BASE_URL,
35
+ DEFAULT_MAX_RETRIES,
36
+ DEFAULT_RETRY_DELAY,
37
+ DEFAULT_TIMEOUT_SECONDS,
38
+ ClientConfig,
39
+ RetryDelay,
40
+ )
41
+ from threecommon.errors.base import APIError
42
+ from threecommon.errors.classes import (
43
+ AuthError,
44
+ ConflictError,
45
+ ConnectionError,
46
+ NotFoundError,
47
+ PermissionError,
48
+ RateLimitError,
49
+ ServerError,
50
+ ValidationError,
51
+ )
52
+ from threecommon.version import VERSION, __version__
53
+
54
+ __all__ = (
55
+ # Constants
56
+ "API_PATH",
57
+ "API_VERSION",
58
+ "DEFAULT_BASE_URL",
59
+ "DEFAULT_MAX_RETRIES",
60
+ "DEFAULT_RETRY_DELAY",
61
+ "DEFAULT_TIMEOUT_SECONDS",
62
+ "VERSION",
63
+ # Errors
64
+ "APIError",
65
+ "AsyncThreeCommon",
66
+ "AuthError",
67
+ "ClientConfig",
68
+ "ConflictError",
69
+ "ConnectionError",
70
+ "NotFoundError",
71
+ "PermissionError",
72
+ "RateLimitError",
73
+ "RetryDelay",
74
+ "ServerError",
75
+ # Clients
76
+ "ThreeCommon",
77
+ "ValidationError",
78
+ "__version__",
79
+ )
@@ -0,0 +1,6 @@
1
+ """Internal HTTP transport. Not part of the public API.
2
+
3
+ Decomposed into one concern per file: URL building, header construction,
4
+ retry policy, response parsing, telemetry header. The sync/async clients in
5
+ [http_client][threecommon._core.http_client] orchestrate them.
6
+ """
@@ -0,0 +1,41 @@
1
+ """Header builder. Pure function over already-resolved values."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import platform
6
+ import sys
7
+
8
+
9
+ def user_agent_suffix(extra: str | None = None) -> str:
10
+ """Runtime + OS portion of the ``User-Agent`` header."""
11
+ parts = [
12
+ f"Python/{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}",
13
+ f"{platform.system()}-{platform.machine()}",
14
+ ]
15
+ if extra:
16
+ parts.append(extra)
17
+ return "; ".join(parts)
18
+
19
+
20
+ def build_headers(
21
+ *,
22
+ api_key: str,
23
+ api_version: str,
24
+ sdk_version: str,
25
+ user_agent_extra: str | None = None,
26
+ telemetry_header: str | None = None,
27
+ idempotency_key: str | None = None,
28
+ ) -> dict[str, str]:
29
+ """Return a fresh header dict populated with every header the SDK sends."""
30
+ headers: dict[str, str] = {
31
+ "Authorization": f"Bearer {api_key}",
32
+ "Threecommon-Version": api_version,
33
+ "User-Agent": f"ThreeCommonPython/{sdk_version} ({user_agent_suffix(user_agent_extra)})",
34
+ "Accept": "application/json",
35
+ "Content-Type": "application/json",
36
+ }
37
+ if telemetry_header:
38
+ headers["Threecommon-Client-Telemetry"] = telemetry_header
39
+ if idempotency_key:
40
+ headers["Idempotency-Key"] = idempotency_key
41
+ return headers
@@ -0,0 +1,424 @@
1
+ """Sync + async request orchestrators.
2
+
3
+ Both classes wrap an [httpx][https://www.python-httpx.org/] client and
4
+ compose the pure modules in this folder into a complete request lifecycle:
5
+ build URL → build headers → send → parse → map errors → retry. The
6
+ sync/async split is at the I/O boundary only; everything else is shared.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ import logging
13
+ import time
14
+ from dataclasses import dataclass, field
15
+ from http import HTTPStatus
16
+ from typing import Any
17
+
18
+ import httpx
19
+
20
+ from threecommon._core.headers import build_headers
21
+ from threecommon._core.parse import (
22
+ parse_error_body,
23
+ parse_retry_after,
24
+ parse_success_body,
25
+ request_id_of,
26
+ )
27
+ from threecommon._core.retry import (
28
+ RetryPolicy,
29
+ compute_backoff,
30
+ is_idempotent,
31
+ is_retryable_status,
32
+ )
33
+ from threecommon._core.telemetry import Telemetry
34
+ from threecommon._core.url import build_url
35
+ from threecommon.api_version import API_PATH
36
+ from threecommon.errors.base import APIError
37
+ from threecommon.errors.classes import (
38
+ AuthError,
39
+ ConflictError,
40
+ ConnectionError,
41
+ NotFoundError,
42
+ PermissionError,
43
+ RateLimitError,
44
+ ServerError,
45
+ ValidationError,
46
+ )
47
+ from threecommon.version import VERSION
48
+
49
+
50
+ @dataclass(slots=True)
51
+ class Request:
52
+ """One logical SDK call. The HTTP clients fill in the rest."""
53
+
54
+ method: str
55
+ path: str
56
+ query: dict[str, str] | None = None
57
+ body: dict[str, Any] | None = None
58
+ idempotency_key: str | None = None
59
+ timeout_seconds: float | None = None
60
+ max_retries: int | None = None # negative → disable retries for this call
61
+
62
+
63
+ @dataclass(slots=True)
64
+ class _Resolved:
65
+ """Pre-resolved per-call values shared by sync + async paths."""
66
+
67
+ url: str
68
+ method: str
69
+ body: dict[str, Any] | None
70
+ idempotency_key: str | None
71
+ max_retries: int
72
+ timeout_seconds: float
73
+ is_idempotent: bool
74
+
75
+
76
+ # ────────────────────────────────────────────────────────────────────────────
77
+ # Common (pure) helpers
78
+ # ────────────────────────────────────────────────────────────────────────────
79
+
80
+
81
+ def _resolve(
82
+ req: Request,
83
+ *,
84
+ base_url: str,
85
+ api_version_header: str,
86
+ default_timeout: float,
87
+ default_max_retries: int,
88
+ ) -> _Resolved:
89
+ _ = api_version_header
90
+ max_retries = default_max_retries
91
+ if req.max_retries is not None:
92
+ max_retries = 0 if req.max_retries < 0 else req.max_retries
93
+ return _Resolved(
94
+ url=build_url(base_url=base_url, api_path=API_PATH, path=req.path, query=req.query),
95
+ method=req.method.upper(),
96
+ body=req.body,
97
+ idempotency_key=req.idempotency_key,
98
+ max_retries=max_retries,
99
+ timeout_seconds=req.timeout_seconds if req.timeout_seconds is not None else default_timeout,
100
+ is_idempotent=is_idempotent(
101
+ req.method.upper(), has_idempotency_key=req.idempotency_key is not None
102
+ ),
103
+ )
104
+
105
+
106
+ # Status-code -> typed-exception mapping. ValidationError is the catch-all
107
+ # for any unmapped 4xx; ServerError covers >= 500.
108
+ _STATUS_TO_ERROR: dict[int, type[APIError]] = {
109
+ HTTPStatus.UNAUTHORIZED: AuthError,
110
+ HTTPStatus.FORBIDDEN: PermissionError,
111
+ HTTPStatus.NOT_FOUND: NotFoundError,
112
+ HTTPStatus.CONFLICT: ConflictError,
113
+ HTTPStatus.BAD_REQUEST: ValidationError,
114
+ HTTPStatus.UNPROCESSABLE_ENTITY: ValidationError,
115
+ }
116
+
117
+ # Status-code -> SDK error.code default. Used when the API didn't return a
118
+ # parsable error envelope.
119
+ _STATUS_TO_CODE: dict[int, str] = {
120
+ HTTPStatus.UNAUTHORIZED: "unauthorized",
121
+ HTTPStatus.FORBIDDEN: "forbidden",
122
+ HTTPStatus.NOT_FOUND: "not_found",
123
+ HTTPStatus.CONFLICT: "conflict",
124
+ HTTPStatus.TOO_MANY_REQUESTS: "rate_limit_exceeded",
125
+ }
126
+
127
+
128
+ def _map_error_response(response: httpx.Response, retry_after: float | None) -> APIError:
129
+ """Turn a non-2xx response into the typed exception subclass."""
130
+ code, message, details = parse_error_body(response.text)
131
+ if not code:
132
+ code = _default_code_for_status(response.status_code)
133
+ if not message:
134
+ message = f"Request failed with status {response.status_code}"
135
+
136
+ base_kwargs: dict[str, Any] = {
137
+ "code": code,
138
+ "message": message,
139
+ "http_status": response.status_code,
140
+ "request_id": request_id_of(response),
141
+ "details": details,
142
+ "raw_response": response.text or None,
143
+ }
144
+
145
+ status = response.status_code
146
+ if status == HTTPStatus.TOO_MANY_REQUESTS:
147
+ return RateLimitError(**base_kwargs, retry_after_seconds=retry_after)
148
+ if status in _STATUS_TO_ERROR:
149
+ return _STATUS_TO_ERROR[status](**base_kwargs)
150
+ if status >= HTTPStatus.INTERNAL_SERVER_ERROR:
151
+ return ServerError(**base_kwargs)
152
+ return ValidationError(**base_kwargs)
153
+
154
+
155
+ def _wrap_connection(message: str, cause: BaseException) -> ConnectionError:
156
+ return ConnectionError(code="connection_error", message=message, cause=cause)
157
+
158
+
159
+ def _default_code_for_status(status: int) -> str:
160
+ code = _STATUS_TO_CODE.get(status)
161
+ if code is not None:
162
+ return code
163
+ if status >= HTTPStatus.INTERNAL_SERVER_ERROR:
164
+ return "internal_error"
165
+ return "request_failed"
166
+
167
+
168
+ def _build_request_headers(
169
+ *,
170
+ api_key: str,
171
+ api_version: str,
172
+ telemetry: Telemetry,
173
+ idempotency_key: str | None,
174
+ user_agent_extra: str | None,
175
+ ) -> dict[str, str]:
176
+ return build_headers(
177
+ api_key=api_key,
178
+ api_version=api_version,
179
+ sdk_version=VERSION,
180
+ user_agent_extra=user_agent_extra,
181
+ telemetry_header=telemetry.header_value(sdk_version=VERSION, api_version=api_version),
182
+ idempotency_key=idempotency_key,
183
+ )
184
+
185
+
186
+ # ────────────────────────────────────────────────────────────────────────────
187
+ # Sync
188
+ # ────────────────────────────────────────────────────────────────────────────
189
+
190
+
191
+ @dataclass(slots=True)
192
+ class HTTPClientOptions:
193
+ """Configuration accepted by :class:`HTTPClient` / :class:`AsyncHTTPClient`."""
194
+
195
+ api_key: str
196
+ base_url: str
197
+ api_version: str
198
+ timeout_seconds: float
199
+ retry: RetryPolicy
200
+ telemetry: Telemetry
201
+ logger: logging.Logger | None = None
202
+ user_agent_extra: str | None = None
203
+ httpx_client: httpx.Client | None = field(default=None, repr=False)
204
+ async_httpx_client: httpx.AsyncClient | None = field(default=None, repr=False)
205
+
206
+
207
+ class HTTPClient:
208
+ """Sync request orchestrator. One instance per [ThreeCommon] client."""
209
+
210
+ __slots__ = ("_opts", "_owns_httpx", "httpx")
211
+
212
+ def __init__(self, opts: HTTPClientOptions) -> None:
213
+ self._opts = opts
214
+ if opts.httpx_client is not None:
215
+ self.httpx = opts.httpx_client
216
+ self._owns_httpx = False
217
+ else:
218
+ self.httpx = httpx.Client(timeout=opts.timeout_seconds)
219
+ self._owns_httpx = True
220
+
221
+ def close(self) -> None:
222
+ """Close the underlying httpx client if we created it."""
223
+ if self._owns_httpx:
224
+ self.httpx.close()
225
+
226
+ def request(self, req: Request) -> Any:
227
+ """Send a request honoring the client's retry policy.
228
+
229
+ Returns the decoded JSON body for 2xx responses, or raises a
230
+ [APIError][threecommon.APIError] subclass.
231
+ """
232
+ resolved = _resolve(
233
+ req,
234
+ base_url=self._opts.base_url,
235
+ api_version_header=self._opts.api_version,
236
+ default_timeout=self._opts.timeout_seconds,
237
+ default_max_retries=self._opts.retry.max_retries,
238
+ )
239
+
240
+ attempt = 0
241
+ while True:
242
+ headers = _build_request_headers(
243
+ api_key=self._opts.api_key,
244
+ api_version=self._opts.api_version,
245
+ telemetry=self._opts.telemetry,
246
+ idempotency_key=resolved.idempotency_key,
247
+ user_agent_extra=self._opts.user_agent_extra,
248
+ )
249
+
250
+ start = time.monotonic()
251
+ try:
252
+ response = self.httpx.request(
253
+ method=resolved.method,
254
+ url=resolved.url,
255
+ headers=headers,
256
+ json=resolved.body,
257
+ timeout=resolved.timeout_seconds,
258
+ )
259
+ except (httpx.TimeoutException, httpx.NetworkError, httpx.ProtocolError) as exc:
260
+ if resolved.is_idempotent and attempt < resolved.max_retries:
261
+ time.sleep(
262
+ compute_backoff(
263
+ attempt=attempt, retry_after_seconds=None, policy=self._opts.retry
264
+ )
265
+ )
266
+ attempt += 1
267
+ continue
268
+ raise _wrap_connection(str(exc) or "network error", exc) from exc
269
+
270
+ duration = time.monotonic() - start
271
+ self._opts.telemetry.record(
272
+ method=resolved.method,
273
+ path=req.path,
274
+ status=response.status_code,
275
+ duration_seconds=duration,
276
+ )
277
+ if self._opts.logger is not None:
278
+ self._opts.logger.debug(
279
+ "threecommon:request",
280
+ extra={
281
+ "method": resolved.method,
282
+ "path": req.path,
283
+ "status": response.status_code,
284
+ "duration_ms": int(duration * 1000),
285
+ "request_id": request_id_of(response),
286
+ "attempt": attempt,
287
+ },
288
+ )
289
+
290
+ if response.is_success:
291
+ return parse_success_body(response.text)
292
+
293
+ retry_after = parse_retry_after(response.headers.get("retry-after"))
294
+ if (
295
+ resolved.is_idempotent
296
+ and attempt < resolved.max_retries
297
+ and is_retryable_status(response.status_code)
298
+ ):
299
+ time.sleep(
300
+ compute_backoff(
301
+ attempt=attempt, retry_after_seconds=retry_after, policy=self._opts.retry
302
+ )
303
+ )
304
+ attempt += 1
305
+ continue
306
+
307
+ raise _map_error_response(response, retry_after)
308
+
309
+
310
+ # ────────────────────────────────────────────────────────────────────────────
311
+ # Async
312
+ # ────────────────────────────────────────────────────────────────────────────
313
+
314
+
315
+ class AsyncHTTPClient:
316
+ """Async request orchestrator. One instance per [AsyncThreeCommon] client."""
317
+
318
+ __slots__ = ("_opts", "_owns_httpx", "httpx")
319
+
320
+ def __init__(self, opts: HTTPClientOptions) -> None:
321
+ self._opts = opts
322
+ if opts.async_httpx_client is not None:
323
+ self.httpx = opts.async_httpx_client
324
+ self._owns_httpx = False
325
+ else:
326
+ self.httpx = httpx.AsyncClient(timeout=opts.timeout_seconds)
327
+ self._owns_httpx = True
328
+
329
+ async def aclose(self) -> None:
330
+ """Close the underlying httpx client if we created it."""
331
+ if self._owns_httpx:
332
+ await self.httpx.aclose()
333
+
334
+ async def request(self, req: Request) -> Any:
335
+ """Send a request honoring the client's retry policy.
336
+
337
+ Async variant of [HTTPClient.request].
338
+ """
339
+ resolved = _resolve(
340
+ req,
341
+ base_url=self._opts.base_url,
342
+ api_version_header=self._opts.api_version,
343
+ default_timeout=self._opts.timeout_seconds,
344
+ default_max_retries=self._opts.retry.max_retries,
345
+ )
346
+
347
+ attempt = 0
348
+ while True:
349
+ headers = _build_request_headers(
350
+ api_key=self._opts.api_key,
351
+ api_version=self._opts.api_version,
352
+ telemetry=self._opts.telemetry,
353
+ idempotency_key=resolved.idempotency_key,
354
+ user_agent_extra=self._opts.user_agent_extra,
355
+ )
356
+
357
+ start = time.monotonic()
358
+ try:
359
+ response = await self.httpx.request(
360
+ method=resolved.method,
361
+ url=resolved.url,
362
+ headers=headers,
363
+ json=resolved.body,
364
+ timeout=resolved.timeout_seconds,
365
+ )
366
+ except (httpx.TimeoutException, httpx.NetworkError, httpx.ProtocolError) as exc:
367
+ if resolved.is_idempotent and attempt < resolved.max_retries:
368
+ await asyncio.sleep(
369
+ compute_backoff(
370
+ attempt=attempt, retry_after_seconds=None, policy=self._opts.retry
371
+ ),
372
+ )
373
+ attempt += 1
374
+ continue
375
+ raise _wrap_connection(str(exc) or "network error", exc) from exc
376
+
377
+ duration = time.monotonic() - start
378
+ self._opts.telemetry.record(
379
+ method=resolved.method,
380
+ path=req.path,
381
+ status=response.status_code,
382
+ duration_seconds=duration,
383
+ )
384
+ if self._opts.logger is not None:
385
+ self._opts.logger.debug(
386
+ "threecommon:request",
387
+ extra={
388
+ "method": resolved.method,
389
+ "path": req.path,
390
+ "status": response.status_code,
391
+ "duration_ms": int(duration * 1000),
392
+ "request_id": request_id_of(response),
393
+ "attempt": attempt,
394
+ },
395
+ )
396
+
397
+ if response.is_success:
398
+ return parse_success_body(response.text)
399
+
400
+ retry_after = parse_retry_after(response.headers.get("retry-after"))
401
+ if (
402
+ resolved.is_idempotent
403
+ and attempt < resolved.max_retries
404
+ and is_retryable_status(response.status_code)
405
+ ):
406
+ await asyncio.sleep(
407
+ compute_backoff(
408
+ attempt=attempt,
409
+ retry_after_seconds=retry_after,
410
+ policy=self._opts.retry,
411
+ ),
412
+ )
413
+ attempt += 1
414
+ continue
415
+
416
+ raise _map_error_response(response, retry_after)
417
+
418
+
419
+ __all__ = (
420
+ "AsyncHTTPClient",
421
+ "HTTPClient",
422
+ "HTTPClientOptions",
423
+ "Request",
424
+ )
@@ -0,0 +1,77 @@
1
+ """Response parsing helpers. Pure functions over text bodies."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from datetime import datetime, timezone
7
+ from email.utils import parsedate_to_datetime
8
+ from typing import Any
9
+
10
+ import httpx
11
+
12
+
13
+ def parse_success_body(body_text: str) -> Any:
14
+ """Decode a 2xx body. Empty or non-JSON resolves to ``None``."""
15
+ if not body_text:
16
+ return None
17
+ try:
18
+ return json.loads(body_text)
19
+ except json.JSONDecodeError:
20
+ return None
21
+
22
+
23
+ def parse_error_body(body_text: str) -> tuple[str, str, dict[str, Any] | None]:
24
+ """Trying to parse the API's standard ``{"error": {...}}`` envelope.
25
+
26
+ Returns ``("", "", None)`` when the body is missing or malformed; callers
27
+ fall back to status-based defaults.
28
+ """
29
+ if not body_text:
30
+ return ("", "", None)
31
+ try:
32
+ envelope = json.loads(body_text)
33
+ except json.JSONDecodeError:
34
+ return ("", "", None)
35
+ if not isinstance(envelope, dict):
36
+ return ("", "", None)
37
+ err = envelope.get("error")
38
+ if not isinstance(err, dict):
39
+ return ("", "", None)
40
+ code = err.get("code", "") if isinstance(err.get("code"), str) else ""
41
+ message = err.get("message", "") if isinstance(err.get("message"), str) else ""
42
+ details = err.get("details") if isinstance(err.get("details"), dict) else None
43
+ return (code, message, details)
44
+
45
+
46
+ def parse_retry_after(header: str | None) -> float | None:
47
+ """Parse a ``Retry-After`` header into seconds.
48
+
49
+ Accepts either a delta-seconds value or an HTTP-date. Returns ``None``
50
+ when the header is missing or malformed; ``0`` when the date is in the
51
+ past.
52
+ """
53
+ if not header:
54
+ return None
55
+ try:
56
+ seconds = float(header)
57
+ except ValueError:
58
+ pass
59
+ else:
60
+ return max(seconds, 0.0)
61
+
62
+ try:
63
+ target = parsedate_to_datetime(header)
64
+ except (TypeError, ValueError):
65
+ return None
66
+
67
+ now = datetime.now(tz=timezone.utc)
68
+ if target.tzinfo is None:
69
+ target = target.replace(tzinfo=timezone.utc)
70
+ delta = (target - now).total_seconds()
71
+ return max(delta, 0.0)
72
+
73
+
74
+ def request_id_of(response: httpx.Response) -> str | None:
75
+ """Return the ``X-Request-Id`` header value, or ``None``."""
76
+ value: str | None = response.headers.get("x-request-id")
77
+ return value