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.
threecommon/client.py ADDED
@@ -0,0 +1,184 @@
1
+ """SDK clients: [ThreeCommon][] (sync) and [AsyncThreeCommon][] (async).
2
+
3
+ Construct once per process; both classes are safe to share across threads and
4
+ tasks. Each instance owns one underlying ``httpx`` client unless you supply
5
+ your own via the ``http_client`` / ``async_http_client`` kwarg.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from types import TracebackType
11
+ from typing import TYPE_CHECKING
12
+
13
+ from threecommon._core.http_client import (
14
+ AsyncHTTPClient,
15
+ HTTPClient,
16
+ HTTPClientOptions,
17
+ )
18
+ from threecommon._core.retry import RetryPolicy
19
+ from threecommon._core.telemetry import Telemetry
20
+ from threecommon.config import RetryDelay, resolve_config
21
+ from threecommon.events.service import AsyncEventsService, EventsService
22
+ from threecommon.invoices.service import AsyncInvoicesService, InvoicesService
23
+
24
+ if TYPE_CHECKING: # pragma: no cover
25
+ import logging
26
+
27
+ import httpx
28
+
29
+
30
+ class ThreeCommon:
31
+ """Synchronous entry point.
32
+
33
+ Construct with at minimum an API key, then call any resource method.
34
+ Closing is optional but recommended for short-lived scripts:
35
+
36
+ client = ThreeCommon(api_key="3co_...")
37
+ try:
38
+ result = client.events.list()
39
+ finally:
40
+ client.close()
41
+
42
+ Or use as a context manager:
43
+
44
+ with ThreeCommon(api_key="3co_...") as client:
45
+ result = client.events.list()
46
+ """
47
+
48
+ events: EventsService
49
+ """Events resource — ``GET /v1/events``, ``GET /v1/events/{id}``, ``PATCH /v1/events/{id}``."""
50
+
51
+ invoices: InvoicesService
52
+ """Invoices resource — list, retrieve, create, update, finalize, void, record_payment."""
53
+
54
+ _http: HTTPClient
55
+ _telemetry: Telemetry
56
+
57
+ def __init__(
58
+ self,
59
+ *,
60
+ api_key: str | None = None,
61
+ base_url: str | None = None,
62
+ api_version: str | None = None,
63
+ timeout_seconds: float | None = None,
64
+ max_retries: int | None = None,
65
+ retry_delay: RetryDelay | None = None,
66
+ http_client: httpx.Client | None = None,
67
+ logger: logging.Logger | None = None,
68
+ telemetry: bool | None = None,
69
+ ) -> None:
70
+ cfg = resolve_config(
71
+ api_key=api_key,
72
+ base_url=base_url,
73
+ api_version=api_version,
74
+ timeout_seconds=timeout_seconds,
75
+ max_retries=max_retries,
76
+ retry_delay=retry_delay,
77
+ http_client=http_client,
78
+ logger=logger,
79
+ telemetry=telemetry,
80
+ )
81
+ self._telemetry = Telemetry(enabled=cfg.telemetry)
82
+ self._http = HTTPClient(
83
+ HTTPClientOptions(
84
+ api_key=cfg.api_key,
85
+ base_url=cfg.base_url,
86
+ api_version=cfg.api_version,
87
+ timeout_seconds=cfg.timeout_seconds,
88
+ retry=RetryPolicy.from_delay(cfg.max_retries, cfg.retry_delay),
89
+ telemetry=self._telemetry,
90
+ logger=cfg.logger,
91
+ httpx_client=cfg.http_client,
92
+ )
93
+ )
94
+ self.events = EventsService(self._http)
95
+ self.invoices = InvoicesService(self._http)
96
+
97
+ def close(self) -> None:
98
+ """Close the underlying httpx client (no-op if you supplied your own)."""
99
+ self._http.close()
100
+
101
+ def disable_telemetry(self) -> None:
102
+ """Stop sending the ``Threecommon-Client-Telemetry`` header at runtime."""
103
+ self._telemetry.disable()
104
+
105
+ def __enter__(self) -> ThreeCommon:
106
+ return self
107
+
108
+ def __exit__(
109
+ self,
110
+ exc_type: type[BaseException] | None,
111
+ exc: BaseException | None,
112
+ tb: TracebackType | None,
113
+ ) -> None:
114
+ del exc_type, exc, tb
115
+ self.close()
116
+
117
+
118
+ class AsyncThreeCommon:
119
+ """Asynchronous entry point. Same surface as [ThreeCommon] with `await`-able methods."""
120
+
121
+ events: AsyncEventsService
122
+ invoices: AsyncInvoicesService
123
+
124
+ _http: AsyncHTTPClient
125
+ _telemetry: Telemetry
126
+
127
+ def __init__(
128
+ self,
129
+ *,
130
+ api_key: str | None = None,
131
+ base_url: str | None = None,
132
+ api_version: str | None = None,
133
+ timeout_seconds: float | None = None,
134
+ max_retries: int | None = None,
135
+ retry_delay: RetryDelay | None = None,
136
+ async_http_client: httpx.AsyncClient | None = None,
137
+ logger: logging.Logger | None = None,
138
+ telemetry: bool | None = None,
139
+ ) -> None:
140
+ cfg = resolve_config(
141
+ api_key=api_key,
142
+ base_url=base_url,
143
+ api_version=api_version,
144
+ timeout_seconds=timeout_seconds,
145
+ max_retries=max_retries,
146
+ retry_delay=retry_delay,
147
+ async_http_client=async_http_client,
148
+ logger=logger,
149
+ telemetry=telemetry,
150
+ )
151
+ self._telemetry = Telemetry(enabled=cfg.telemetry)
152
+ self._http = AsyncHTTPClient(
153
+ HTTPClientOptions(
154
+ api_key=cfg.api_key,
155
+ base_url=cfg.base_url,
156
+ api_version=cfg.api_version,
157
+ timeout_seconds=cfg.timeout_seconds,
158
+ retry=RetryPolicy.from_delay(cfg.max_retries, cfg.retry_delay),
159
+ telemetry=self._telemetry,
160
+ logger=cfg.logger,
161
+ async_httpx_client=cfg.async_http_client,
162
+ )
163
+ )
164
+ self.events = AsyncEventsService(self._http)
165
+ self.invoices = AsyncInvoicesService(self._http)
166
+
167
+ async def aclose(self) -> None:
168
+ """Close the underlying async httpx client."""
169
+ await self._http.aclose()
170
+
171
+ def disable_telemetry(self) -> None:
172
+ self._telemetry.disable()
173
+
174
+ async def __aenter__(self) -> AsyncThreeCommon:
175
+ return self
176
+
177
+ async def __aexit__(
178
+ self,
179
+ exc_type: type[BaseException] | None,
180
+ exc: BaseException | None,
181
+ tb: TracebackType | None,
182
+ ) -> None:
183
+ del exc_type, exc, tb
184
+ await self.aclose()
threecommon/config.py ADDED
@@ -0,0 +1,140 @@
1
+ """Resolved client configuration.
2
+
3
+ User do not construct [ClientConfig][threecommon.config.ClientConfig]
4
+ directly — they pass keyword arguments to [ThreeCommon][threecommon.ThreeCommon]
5
+ or [AsyncThreeCommon][threecommon.AsyncThreeCommon], which build a
6
+ ``ClientConfig`` internally after validation.
7
+
8
+ This module also exposes the named defaults so the public docs can reference
9
+ them.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import os
15
+ from dataclasses import dataclass, field
16
+ from typing import TYPE_CHECKING
17
+
18
+ from threecommon.api_version import API_VERSION
19
+ from threecommon.errors.classes import ValidationError
20
+
21
+ if TYPE_CHECKING: # pragma: no cover
22
+ import logging
23
+
24
+ import httpx
25
+
26
+
27
+ #: Default API base URL.
28
+ DEFAULT_BASE_URL = "https://api.3common.com"
29
+
30
+ #: Default per-request deadline.
31
+ DEFAULT_TIMEOUT_SECONDS = 30.0
32
+
33
+ #: Default number of retry attempts for idempotent requests on retryable
34
+ #: failures (408, 425, 429, 5xx, network errors).
35
+ DEFAULT_MAX_RETRIES = 3
36
+
37
+ #: Environment variable consulted when ``api_key`` is not passed explicitly.
38
+ ENV_VAR_API_KEY = "THREECOMMON_API_KEY"
39
+
40
+
41
+ @dataclass(frozen=True, slots=True)
42
+ class RetryDelay:
43
+ """Exponential-backoff schedule.
44
+
45
+ Backoff doubles each attempt, capped at :attr:`max_seconds`. When
46
+ :attr:`jitter` is true the SDK picks a random value in
47
+ ``[0, capped]`` for the actual sleep.
48
+ """
49
+
50
+ initial_seconds: float = 0.5
51
+ max_seconds: float = 8.0
52
+ jitter: bool = True
53
+
54
+
55
+ #: Default backoff schedule applied when ``retry_delay`` isn't passed.
56
+ DEFAULT_RETRY_DELAY = RetryDelay()
57
+
58
+
59
+ @dataclass(frozen=True, slots=True)
60
+ class ClientConfig:
61
+ """Internal frozen view of the resolved client configuration.
62
+
63
+ Construct via :func:`resolve_config` rather than directly.
64
+ """
65
+
66
+ api_key: str
67
+ base_url: str
68
+ api_version: str
69
+ timeout_seconds: float
70
+ max_retries: int
71
+ retry_delay: RetryDelay
72
+ http_client: httpx.Client | None = None
73
+ async_http_client: httpx.AsyncClient | None = None
74
+ logger: logging.Logger | None = None
75
+ telemetry: bool = True
76
+ user_agent_extra: str | None = field(default=None, repr=False)
77
+
78
+
79
+ def resolve_config(
80
+ *,
81
+ api_key: str | None,
82
+ base_url: str | None,
83
+ api_version: str | None,
84
+ timeout_seconds: float | None,
85
+ max_retries: int | None,
86
+ retry_delay: RetryDelay | None,
87
+ http_client: httpx.Client | None = None,
88
+ async_http_client: httpx.AsyncClient | None = None,
89
+ logger: logging.Logger | None = None,
90
+ telemetry: bool | None = None,
91
+ ) -> ClientConfig:
92
+ """Validate constructor kwargs and return a frozen [ClientConfig].
93
+
94
+ Raises [ValidationError][threecommon.ValidationError] for missing API
95
+ key or invalid numeric ranges; the message names the exact field so
96
+ customers don't have to read a stack trace.
97
+ """
98
+ resolved_key = api_key or os.environ.get(ENV_VAR_API_KEY, "")
99
+ if not resolved_key:
100
+ raise ValidationError(
101
+ code="missing_api_key",
102
+ message=(
103
+ "An API key is required. Pass `api_key` to the ThreeCommon "
104
+ f"constructor, or set the {ENV_VAR_API_KEY} environment variable."
105
+ ),
106
+ )
107
+
108
+ resolved_base = (base_url or DEFAULT_BASE_URL).rstrip("/")
109
+ if not resolved_base.startswith(("http://", "https://")):
110
+ raise ValidationError(
111
+ code="invalid_base_url",
112
+ message=f"base_url must start with http:// or https://; got {base_url!r}.",
113
+ )
114
+
115
+ resolved_timeout = timeout_seconds if timeout_seconds is not None else DEFAULT_TIMEOUT_SECONDS
116
+ if resolved_timeout <= 0:
117
+ raise ValidationError(
118
+ code="invalid_timeout",
119
+ message=f"timeout_seconds must be positive; got {resolved_timeout!r}.",
120
+ )
121
+
122
+ resolved_retries = max_retries if max_retries is not None else DEFAULT_MAX_RETRIES
123
+ if resolved_retries < 0:
124
+ raise ValidationError(
125
+ code="invalid_max_retries",
126
+ message=f"max_retries must be non-negative; got {resolved_retries!r}.",
127
+ )
128
+
129
+ return ClientConfig(
130
+ api_key=resolved_key,
131
+ base_url=resolved_base,
132
+ api_version=api_version or API_VERSION,
133
+ timeout_seconds=resolved_timeout,
134
+ max_retries=resolved_retries,
135
+ retry_delay=retry_delay or DEFAULT_RETRY_DELAY,
136
+ http_client=http_client,
137
+ async_http_client=async_http_client,
138
+ logger=logger,
139
+ telemetry=True if telemetry is None else telemetry,
140
+ )
@@ -0,0 +1,35 @@
1
+ """Errors module — re-exports the base [APIError][threecommon.APIError] and
2
+ every typed subtype.
3
+
4
+ Customers usually catch via the root re-exports::
5
+
6
+ from threecommon import NotFoundError
7
+
8
+ but importing the submodule directly also works::
9
+
10
+ from threecommon.errors import NotFoundError
11
+ """
12
+
13
+ from threecommon.errors.base import APIError
14
+ from threecommon.errors.classes import (
15
+ AuthError,
16
+ ConflictError,
17
+ ConnectionError,
18
+ NotFoundError,
19
+ PermissionError,
20
+ RateLimitError,
21
+ ServerError,
22
+ ValidationError,
23
+ )
24
+
25
+ __all__ = (
26
+ "APIError",
27
+ "AuthError",
28
+ "ConflictError",
29
+ "ConnectionError",
30
+ "NotFoundError",
31
+ "PermissionError",
32
+ "RateLimitError",
33
+ "ServerError",
34
+ "ValidationError",
35
+ )
@@ -0,0 +1,81 @@
1
+ """Base exception type carried by every error the SDK raises.
2
+
3
+ The HTTP-status-specific subtypes ([AuthError][threecommon.AuthError],
4
+ [NotFoundError][threecommon.NotFoundError], ...) all inherit from
5
+ [APIError][threecommon.APIError]. Branch on the subtype with a normal
6
+ `except` clause:
7
+
8
+ try:
9
+ client.events.retrieve("evt_missing")
10
+ except threecommon.NotFoundError as e:
11
+ log.warning("missing event %s", e.request_id)
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from typing import Any
17
+
18
+
19
+ class APIError(Exception):
20
+ """Base class for every error raised by the SDK.
21
+
22
+ Every field is best-effort: ``http_status`` is ``None`` for connection
23
+ errors, ``request_id`` is ``None`` when the server didn't return one,
24
+ ``raw_response`` is empty for non-text responses.
25
+ """
26
+
27
+ code: str
28
+ """Stable string matching the API's error.code field, e.g. ``not_found``."""
29
+
30
+ message: str
31
+ """Human-readable description. Safe to surface to end users."""
32
+
33
+ http_status: int | None
34
+ """Response status, or ``None`` if the error originated before any response."""
35
+
36
+ request_id: str | None
37
+ """Value of the ``X-Request-ID`` response header, when present."""
38
+
39
+ details: dict[str, Any] | None
40
+ """Parsed API ``error.details`` payload, when present."""
41
+
42
+ raw_response: str | None
43
+ """Raw response body, retained for debugging."""
44
+
45
+ __cause__: BaseException | None
46
+
47
+ def __init__(
48
+ self,
49
+ *,
50
+ code: str,
51
+ message: str,
52
+ http_status: int | None = None,
53
+ request_id: str | None = None,
54
+ details: dict[str, Any] | None = None,
55
+ raw_response: str | None = None,
56
+ cause: BaseException | None = None,
57
+ ) -> None:
58
+ super().__init__(self._format(code, message, request_id))
59
+ self.code = code
60
+ self.message = message
61
+ self.http_status = http_status
62
+ self.request_id = request_id
63
+ self.details = details
64
+ self.raw_response = raw_response
65
+ if cause is not None:
66
+ self.__cause__ = cause
67
+
68
+ @staticmethod
69
+ def _format(code: str, message: str, request_id: str | None) -> str:
70
+ if request_id:
71
+ return f"[{code}] {message} (request_id={request_id})"
72
+ return f"[{code}] {message}"
73
+
74
+ def __repr__(self) -> str:
75
+ return (
76
+ f"{self.__class__.__name__}("
77
+ f"code={self.code!r}, "
78
+ f"message={self.message!r}, "
79
+ f"http_status={self.http_status!r}, "
80
+ f"request_id={self.request_id!r})"
81
+ )
@@ -0,0 +1,75 @@
1
+ """HTTP-status-specific exception subtypes.
2
+
3
+ All inherit from [APIError][threecommon.APIError]. Catch the subtype user care
4
+ about; the order of `except` clauses can go from specific to general.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Any
10
+
11
+ from threecommon.errors.base import APIError
12
+
13
+
14
+ class AuthError(APIError):
15
+ """401 Unauthorized — invalid, missing, or expired API key."""
16
+
17
+
18
+ class PermissionError(APIError):
19
+ """403 Forbidden — the API key lacks the scope required by the endpoint."""
20
+
21
+
22
+ class NotFoundError(APIError):
23
+ """404 Not Found."""
24
+
25
+
26
+ class ValidationError(APIError):
27
+ """400 Bad Request and 422 Unprocessable Entity — request validation failed."""
28
+
29
+
30
+ class ConflictError(APIError):
31
+ """409 Conflict — the request conflicts with current resource state."""
32
+
33
+
34
+ class RateLimitError(APIError):
35
+ """429 Too Many Requests.
36
+
37
+ [retry_after_seconds][threecommon.RateLimitError.retry_after_seconds]
38
+ carries the parsed ``Retry-After`` header so user can implement their
39
+ own backoff; it is ``None`` when the server did not provide one.
40
+ """
41
+
42
+ retry_after_seconds: float | None
43
+
44
+ def __init__(
45
+ self,
46
+ *,
47
+ code: str,
48
+ message: str,
49
+ http_status: int | None = None,
50
+ request_id: str | None = None,
51
+ details: dict[str, Any] | None = None,
52
+ raw_response: str | None = None,
53
+ cause: BaseException | None = None,
54
+ retry_after_seconds: float | None = None,
55
+ ) -> None:
56
+ super().__init__(
57
+ code=code,
58
+ message=message,
59
+ http_status=http_status,
60
+ request_id=request_id,
61
+ details=details,
62
+ raw_response=raw_response,
63
+ cause=cause,
64
+ )
65
+ self.retry_after_seconds = retry_after_seconds
66
+
67
+
68
+ class ServerError(APIError):
69
+ """5xx — the API returned an unexpected server-side failure."""
70
+
71
+
72
+ class ConnectionError(APIError):
73
+ """The request never completed: DNS failure, TCP reset, TLS error,
74
+ timeout, etc. The original cause is available via ``__cause__``.
75
+ """
@@ -0,0 +1,28 @@
1
+ """Events resource — sync and async clients plus public types.
2
+
3
+ Most callers reach this module through
4
+ [ThreeCommon.events][threecommon.ThreeCommon] /
5
+ [AsyncThreeCommon.events][threecommon.AsyncThreeCommon]; importing the
6
+ service classes directly is supported for advanced wiring.
7
+ """
8
+
9
+ from threecommon.events.service import AsyncEventsService, EventsService
10
+ from threecommon.events.types import (
11
+ Event,
12
+ EventStatus,
13
+ ListEventsResponse,
14
+ ListParams,
15
+ RetrieveParams,
16
+ UpdateBody,
17
+ )
18
+
19
+ __all__ = (
20
+ "AsyncEventsService",
21
+ "Event",
22
+ "EventStatus",
23
+ "EventsService",
24
+ "ListEventsResponse",
25
+ "ListParams",
26
+ "RetrieveParams",
27
+ "UpdateBody",
28
+ )