cosmoner-sdk 1.1.0__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.
@@ -0,0 +1,26 @@
1
+ # JavaScript
2
+ node_modules/
3
+ dist/
4
+
5
+ # Python
6
+ __pycache__/
7
+ *.egg-info/
8
+ dist/
9
+ build/
10
+ *.pyc
11
+ .pytest_cache/
12
+ .mypy_cache/
13
+ .ruff_cache/
14
+ .venv/
15
+
16
+ # PHP
17
+ vendor/
18
+ .phpunit.result.cache
19
+ .phpcs-cache
20
+ .phpstan.cache/
21
+
22
+ # IDE
23
+ .idea/
24
+ .vscode/
25
+ *.swp
26
+ .DS_Store
@@ -0,0 +1,16 @@
1
+ Metadata-Version: 2.4
2
+ Name: cosmoner-sdk
3
+ Version: 1.1.0
4
+ Summary: Cosmoner SDK for Python
5
+ Project-URL: Homepage, https://cosmoner.com
6
+ License-Expression: MIT
7
+ Keywords: cosmoner,sdk
8
+ Requires-Python: >=3.9
9
+ Requires-Dist: httpx>=0.24
10
+ Provides-Extra: lint
11
+ Requires-Dist: mypy>=1.15; extra == 'lint'
12
+ Requires-Dist: ruff>=0.9; extra == 'lint'
13
+ Provides-Extra: test
14
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'test'
15
+ Requires-Dist: pytest-httpx>=0.28; extra == 'test'
16
+ Requires-Dist: pytest>=7.0; extra == 'test'
@@ -0,0 +1,136 @@
1
+ # cosmoner-sdk
2
+
3
+ Official Cosmoner SDK for Python.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pip install cosmoner-sdk
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ```python
14
+ from cosmoner import Cosmoner
15
+
16
+ client = Cosmoner(
17
+ api_key="your-api-key",
18
+ project_id="your-project-id",
19
+ )
20
+
21
+ # Send to a single recipient
22
+ result = client.email.send(
23
+ credential_id="your-credential-id",
24
+ to="recipient@example.com",
25
+ subject="Hello from Cosmoner",
26
+ html="<h1>Hello!</h1><p>This is a test email.</p>",
27
+ )
28
+ print(result["data"]["messageId"])
29
+
30
+ # Send to multiple recipients (up to 50)
31
+ client.email.send(
32
+ credential_id="your-credential-id",
33
+ to=["alice@example.com", "bob@example.com"],
34
+ subject="Team Update",
35
+ text="Plain text email body",
36
+ reply_to="noreply@yourdomain.com",
37
+ )
38
+ ```
39
+
40
+ ## Configuration
41
+
42
+ | Parameter | Required | Default | Description |
43
+ | ------------- | -------- | -------------------------- | -------------------------------------------------- |
44
+ | `api_key` | Yes | — | Your Cosmoner API key |
45
+ | `project_id` | No | — | Default project; can also be passed per call |
46
+ | `base_url` | No | `https://api.cosmoner.com` | API base URL override |
47
+ | `timeout` | No | `30.0` | Per-request timeout in seconds |
48
+ | `max_retries` | No | `2` | Retries for transient failures (see [Retries](#retries)) |
49
+
50
+ `project_id` is optional so one client can span projects. Pass it per call to
51
+ override the default:
52
+
53
+ ```python
54
+ client = Cosmoner(api_key="your-api-key")
55
+
56
+ client.email.send(project_id="proj-2", credential_id="...", to="...", subject="...", text="...")
57
+ ```
58
+
59
+ ## Async
60
+
61
+ `AsyncCosmoner` mirrors the sync client method for method:
62
+
63
+ ```python
64
+ from cosmoner import AsyncCosmoner
65
+
66
+ async with AsyncCosmoner(api_key="your-api-key", project_id="your-project-id") as client:
67
+ await client.email.send(
68
+ credential_id="your-credential-id",
69
+ to="recipient@example.com",
70
+ subject="Hello",
71
+ text="Hi there",
72
+ )
73
+ ```
74
+
75
+ Both clients hold a connection pool. Use them as context managers, or call
76
+ `client.close()` / `await client.aclose()` when you are done.
77
+
78
+ ## Retries
79
+
80
+ Transient failures are retried automatically with exponential backoff and full
81
+ jitter, honouring `Retry-After` when the API sends it.
82
+
83
+ - **429** is always retried — the request was rejected before it was processed,
84
+ so replaying it cannot duplicate a side effect.
85
+ - **Timeouts and 5xx** are retried only for idempotent methods. A write such as
86
+ `email.send` is *not* replayed, because the API may have completed the send
87
+ before failing to answer.
88
+
89
+ Set `max_retries=0` to disable retries entirely.
90
+
91
+ ## Email
92
+
93
+ ### `client.email.send(...)`
94
+
95
+ | Parameter | Type | Required | Description |
96
+ | --------------- | ------------------------ | -------- | ------------------------------------ |
97
+ | `credential_id` | `str` | Yes | SMTP credential ID |
98
+ | `to` | `str \| list[str]` | Yes | Recipient(s), max 50 |
99
+ | `subject` | `str` | Yes | Email subject line |
100
+ | `html` | `str` | No* | HTML body |
101
+ | `text` | `str` | No* | Plain text body |
102
+ | `reply_to` | `str \| list[str]` | No | Reply-to address(es), max 5 |
103
+ | `project_id` | `str` | No | Overrides the client-level project |
104
+
105
+ \* At least one of `html` or `text` must be provided.
106
+
107
+ ## Error Handling
108
+
109
+ Every failure raises a subclass of `CosmonerError`, so you can catch broadly or
110
+ narrowly:
111
+
112
+ ```python
113
+ from cosmoner import Cosmoner, CosmonerError, RateLimitError
114
+
115
+ try:
116
+ client.email.send(...)
117
+ except RateLimitError as e:
118
+ print(e.retry_after) # seconds, when the API supplies it
119
+ except CosmonerError as e:
120
+ print(e.code) # e.g. "INSUFFICIENT_SCOPE"
121
+ print(e.status) # e.g. 403
122
+ print(e.details) # field-level validation errors, when present
123
+ print(str(e)) # Human-readable message
124
+ ```
125
+
126
+ | Exception | Raised on |
127
+ | -------------------------- | --------------------------------------------- |
128
+ | `ValidationError` | 400, 422 |
129
+ | `AuthenticationError` | 401 |
130
+ | `InsufficientScopeError` | 403 — API key is missing a `resource:action` |
131
+ | `NotFoundError` | 404 |
132
+ | `ConflictError` | 409 |
133
+ | `RateLimitError` | 429 |
134
+ | `ServerError` | 5xx |
135
+ | `CosmonerTimeoutError` | Request exceeded `timeout` |
136
+ | `CosmonerConnectionError` | DNS, TCP, TLS or socket failure |
@@ -0,0 +1,66 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "cosmoner-sdk"
7
+ version = "1.1.0"
8
+ description = "Cosmoner SDK for Python"
9
+ requires-python = ">=3.9"
10
+ license = "MIT"
11
+ dependencies = [
12
+ "httpx>=0.24",
13
+ ]
14
+ keywords = ["cosmoner", "sdk"]
15
+
16
+ [tool.hatch.build.targets.wheel]
17
+ packages = ["src/cosmoner"]
18
+
19
+ [project.optional-dependencies]
20
+ test = [
21
+ "pytest>=7.0",
22
+ "pytest-httpx>=0.28",
23
+ "pytest-asyncio>=0.23",
24
+ ]
25
+ lint = [
26
+ "ruff>=0.9",
27
+ "mypy>=1.15",
28
+ ]
29
+
30
+ [tool.pytest.ini_options]
31
+ testpaths = ["tests"]
32
+ asyncio_mode = "auto"
33
+
34
+ [tool.ruff]
35
+ line-length = 90
36
+ target-version = "py39"
37
+ src = ["src", "tests"]
38
+ extend-exclude = ["*.md"]
39
+
40
+ [tool.ruff.lint]
41
+ select = [
42
+ "E", # pycodestyle errors
43
+ "W", # pycodestyle warnings
44
+ "F", # pyflakes
45
+ "I", # isort
46
+ "B", # flake8-bugbear
47
+ "UP", # pyupgrade
48
+ "SIM", # flake8-simplify
49
+ "RUF", # ruff-specific
50
+ "D", # pydocstyle — every function/class needs a docstring
51
+ ]
52
+
53
+ [tool.ruff.lint.pydocstyle]
54
+ convention = "google"
55
+
56
+ [tool.ruff.lint.per-file-ignores]
57
+ # Tests document themselves through their names; docstrings on every case are noise.
58
+ "tests/*" = ["D"]
59
+
60
+ [tool.mypy]
61
+ files = ["src"]
62
+ strict = true
63
+ warn_unused_ignores = true
64
+
65
+ [project.urls]
66
+ Homepage = "https://cosmoner.com"
@@ -0,0 +1,52 @@
1
+ """Cosmoner SDK for Python."""
2
+
3
+ from ._version import __version__
4
+ from .client import AsyncCosmoner, Cosmoner
5
+ from .errors import (
6
+ AuthenticationError,
7
+ ConflictError,
8
+ CosmonerConnectionError,
9
+ CosmonerError,
10
+ CosmonerTimeoutError,
11
+ InsufficientScopeError,
12
+ NotFoundError,
13
+ RateLimitError,
14
+ ServerError,
15
+ ValidationError,
16
+ WebhookSignatureError,
17
+ )
18
+ from .webhook_signature import (
19
+ DEFAULT_TOLERANCE_SECONDS,
20
+ DELIVERY_ID_HEADER,
21
+ EVENT_TYPE_HEADER,
22
+ SIGNATURE_HEADER,
23
+ construct_event,
24
+ verify_webhook_signature,
25
+ )
26
+ from .webhooks import WEBHOOK_EVENT_TYPES, AsyncWebhooksService, WebhooksService
27
+
28
+ __all__ = [
29
+ "DEFAULT_TOLERANCE_SECONDS",
30
+ "DELIVERY_ID_HEADER",
31
+ "EVENT_TYPE_HEADER",
32
+ "SIGNATURE_HEADER",
33
+ "WEBHOOK_EVENT_TYPES",
34
+ "AsyncCosmoner",
35
+ "AsyncWebhooksService",
36
+ "AuthenticationError",
37
+ "ConflictError",
38
+ "Cosmoner",
39
+ "CosmonerConnectionError",
40
+ "CosmonerError",
41
+ "CosmonerTimeoutError",
42
+ "InsufficientScopeError",
43
+ "NotFoundError",
44
+ "RateLimitError",
45
+ "ServerError",
46
+ "ValidationError",
47
+ "WebhookSignatureError",
48
+ "WebhooksService",
49
+ "__version__",
50
+ "construct_event",
51
+ "verify_webhook_signature",
52
+ ]
@@ -0,0 +1,61 @@
1
+ """Shared, validated configuration for the sync and async clients."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ DEFAULT_BASE_URL = "https://api.cosmoner.com"
8
+ DEFAULT_TIMEOUT = 30.0
9
+ DEFAULT_MAX_RETRIES = 2
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class ClientConfig:
14
+ """Immutable connection settings shared by a client and its service namespaces."""
15
+
16
+ api_key: str
17
+ project_id: str | None
18
+ base_url: str
19
+ timeout: float
20
+ max_retries: int
21
+
22
+
23
+ def build_config(
24
+ api_key: str,
25
+ project_id: str | None,
26
+ base_url: str = DEFAULT_BASE_URL,
27
+ timeout: float = DEFAULT_TIMEOUT,
28
+ max_retries: int = DEFAULT_MAX_RETRIES,
29
+ ) -> ClientConfig:
30
+ """Validates constructor arguments and normalizes them into a ``ClientConfig``.
31
+
32
+ ``project_id`` is optional so a single client can span projects, but an
33
+ explicitly empty string is rejected rather than silently treated as unset —
34
+ that almost always means an unresolved environment variable.
35
+ """
36
+ if not api_key:
37
+ raise ValueError("api_key is required")
38
+ if project_id is not None and not project_id:
39
+ raise ValueError("project_id is required")
40
+ if timeout <= 0:
41
+ raise ValueError("timeout must be greater than 0")
42
+ if max_retries < 0:
43
+ raise ValueError("max_retries cannot be negative")
44
+
45
+ return ClientConfig(
46
+ api_key=api_key,
47
+ project_id=project_id,
48
+ base_url=base_url.rstrip("/"),
49
+ timeout=timeout,
50
+ max_retries=max_retries,
51
+ )
52
+
53
+
54
+ def resolve_project_id(config: ClientConfig, override: str | None = None) -> str:
55
+ """Returns the per-call project id, falling back to the client-level default."""
56
+ project_id = override or config.project_id
57
+ if not project_id:
58
+ raise ValueError(
59
+ "project_id is required — pass it to the Cosmoner client or to this method"
60
+ )
61
+ return project_id
@@ -0,0 +1,84 @@
1
+ """Retry policy governing which failed requests are safe to send again."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import random
6
+ from collections.abc import Mapping
7
+ from dataclasses import dataclass
8
+
9
+ # Methods the HTTP spec defines as idempotent: replaying one cannot create a
10
+ # second side effect, so they are safe to retry after a timeout or a 5xx.
11
+ IDEMPOTENT_METHODS = frozenset({"GET", "HEAD", "OPTIONS", "PUT", "DELETE"})
12
+
13
+ BACKOFF_BASE_SECONDS = 0.5
14
+ BACKOFF_CAP_SECONDS = 8.0
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class RetryPolicy:
19
+ """Decides whether a given failure should be retried, and how long to wait."""
20
+
21
+ max_retries: int
22
+
23
+ def should_retry(
24
+ self,
25
+ *,
26
+ attempt: int,
27
+ method: str,
28
+ status: int | None,
29
+ retry_non_idempotent: bool = False,
30
+ ) -> bool:
31
+ """Returns whether another attempt is warranted.
32
+
33
+ A ``status`` of ``None`` means the request failed at the transport layer
34
+ and never reached the API.
35
+
36
+ 429 is always retryable regardless of method: the request was rejected
37
+ before it was processed, so replaying it cannot duplicate a side effect.
38
+ Timeouts and 5xx are different — the API may well have completed the work
39
+ before failing to answer, so those are only retried for idempotent
40
+ methods, or when the caller opts in because the request carries an
41
+ idempotency key the server honours.
42
+ """
43
+ if attempt >= self.max_retries:
44
+ return False
45
+
46
+ if status == 429:
47
+ return True
48
+
49
+ is_retryable_failure = status is None or status >= 500
50
+ if not is_retryable_failure:
51
+ return False
52
+
53
+ return method.upper() in IDEMPOTENT_METHODS or retry_non_idempotent
54
+
55
+ def backoff_seconds(self, attempt: int, retry_after: float | None = None) -> float:
56
+ """Returns the delay before the next attempt.
57
+
58
+ A server-supplied ``Retry-After`` wins outright. Otherwise the delay is
59
+ exponential with full jitter, which spreads a thundering herd of clients
60
+ that all got rate limited in the same window.
61
+ """
62
+ if retry_after is not None and retry_after >= 0:
63
+ return min(retry_after, BACKOFF_CAP_SECONDS)
64
+
65
+ ceiling = min(BACKOFF_BASE_SECONDS * (2**attempt), BACKOFF_CAP_SECONDS)
66
+ return random.uniform(0, ceiling)
67
+
68
+
69
+ def parse_retry_after(headers: Mapping[str, str]) -> float | None:
70
+ """Reads a ``Retry-After`` header expressed in seconds.
71
+
72
+ The API does not currently send this header; it is honoured so the SDK
73
+ starts respecting it the moment the platform adds it. The HTTP-date form is
74
+ ignored deliberately — it is rare in practice and jittered backoff is a
75
+ perfectly good fallback.
76
+ """
77
+ raw = headers.get("retry-after") or headers.get("Retry-After")
78
+ if raw is None:
79
+ return None
80
+
81
+ try:
82
+ return float(raw)
83
+ except (TypeError, ValueError):
84
+ return None
@@ -0,0 +1,209 @@
1
+ """HTTP transport: header assembly, retries, and response/error decoding."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import time
7
+ import uuid
8
+ from collections.abc import Mapping
9
+ from typing import Any
10
+
11
+ import httpx
12
+
13
+ from ._config import ClientConfig
14
+ from ._retry import RetryPolicy, parse_retry_after
15
+ from ._version import USER_AGENT
16
+ from .errors import (
17
+ CosmonerConnectionError,
18
+ CosmonerError,
19
+ CosmonerTimeoutError,
20
+ error_from_response,
21
+ )
22
+
23
+ # Methods that mutate state and therefore carry an idempotency key.
24
+ WRITE_METHODS = frozenset({"POST", "PUT", "PATCH", "DELETE"})
25
+
26
+
27
+ class _BaseTransport:
28
+ """Shared request construction and response decoding for both transports."""
29
+
30
+ def __init__(self, config: ClientConfig) -> None:
31
+ self._config = config
32
+ self._policy = RetryPolicy(max_retries=config.max_retries)
33
+
34
+ def _url(self, path: str) -> str:
35
+ """Joins a route path onto the configured base URL."""
36
+ return f"{self._config.base_url}{path}"
37
+
38
+ def _headers(
39
+ self,
40
+ method: str,
41
+ has_body: bool,
42
+ idempotency_key: str | None,
43
+ ) -> dict[str, str]:
44
+ """Builds the header set for one request.
45
+
46
+ The idempotency key is generated once per logical request and reused
47
+ across retries, so a replayed write can be collapsed server-side. The
48
+ API does not consume this header yet — it is sent now so that retries
49
+ become safe for writes as soon as it does.
50
+ """
51
+ headers = {
52
+ "Authorization": f"Bearer {self._config.api_key}",
53
+ "Accept": "application/json",
54
+ "User-Agent": USER_AGENT,
55
+ }
56
+ if has_body:
57
+ headers["Content-Type"] = "application/json"
58
+ if method.upper() in WRITE_METHODS:
59
+ headers["Idempotency-Key"] = idempotency_key or str(uuid.uuid4())
60
+ return headers
61
+
62
+ def _decode(self, response: httpx.Response) -> Any:
63
+ """Parses a successful response body, or raises the mapped API error."""
64
+ try:
65
+ body: Any = response.json()
66
+ except ValueError:
67
+ body = None
68
+
69
+ if response.is_success:
70
+ if body is None:
71
+ raise CosmonerError(
72
+ status=response.status_code,
73
+ code="INVALID_RESPONSE",
74
+ message="API returned a non-JSON response",
75
+ )
76
+ return body
77
+
78
+ raise error_from_response(
79
+ status=response.status_code,
80
+ body=body,
81
+ headers=response.headers,
82
+ retry_after=parse_retry_after(response.headers),
83
+ )
84
+
85
+
86
+ class Transport(_BaseTransport):
87
+ """Synchronous HTTP transport backed by a pooled ``httpx.Client``."""
88
+
89
+ def __init__(self, config: ClientConfig) -> None:
90
+ super().__init__(config)
91
+ self._client = httpx.Client(timeout=config.timeout)
92
+
93
+ def request(
94
+ self,
95
+ method: str,
96
+ path: str,
97
+ *,
98
+ json: Mapping[str, Any] | None = None,
99
+ params: Mapping[str, Any] | None = None,
100
+ idempotency_key: str | None = None,
101
+ retry_non_idempotent: bool = False,
102
+ ) -> Any:
103
+ """Issues a request, retrying transient failures, and returns the parsed body."""
104
+ url = self._url(path)
105
+ headers = self._headers(method, json is not None, idempotency_key)
106
+ attempt = 0
107
+
108
+ while True:
109
+ status: int | None = None
110
+ retry_after: float | None = None
111
+ error: CosmonerConnectionError | None = None
112
+
113
+ try:
114
+ response = self._client.request(
115
+ method, url, json=json, params=params, headers=headers
116
+ )
117
+ status = response.status_code
118
+ retry_after = parse_retry_after(response.headers)
119
+
120
+ if response.is_success:
121
+ return self._decode(response)
122
+ except httpx.TimeoutException as err:
123
+ response, error = None, CosmonerTimeoutError(f"Request timed out: {err}")
124
+ except httpx.HTTPError as err:
125
+ response, error = None, CosmonerConnectionError(f"Request failed: {err}")
126
+ else:
127
+ error = None
128
+
129
+ if not self._policy.should_retry(
130
+ attempt=attempt,
131
+ method=method,
132
+ status=status,
133
+ retry_non_idempotent=retry_non_idempotent,
134
+ ):
135
+ if error is not None:
136
+ raise error
137
+ # ``response`` is always set when no transport error was captured.
138
+ assert response is not None
139
+ return self._decode(response) # raises the mapped API error
140
+
141
+ time.sleep(self._policy.backoff_seconds(attempt, retry_after))
142
+ attempt += 1
143
+
144
+ def close(self) -> None:
145
+ """Closes the underlying connection pool."""
146
+ self._client.close()
147
+
148
+
149
+ class AsyncTransport(_BaseTransport):
150
+ """Asynchronous HTTP transport backed by a pooled ``httpx.AsyncClient``."""
151
+
152
+ def __init__(self, config: ClientConfig) -> None:
153
+ super().__init__(config)
154
+ self._client = httpx.AsyncClient(timeout=config.timeout)
155
+
156
+ async def request(
157
+ self,
158
+ method: str,
159
+ path: str,
160
+ *,
161
+ json: Mapping[str, Any] | None = None,
162
+ params: Mapping[str, Any] | None = None,
163
+ idempotency_key: str | None = None,
164
+ retry_non_idempotent: bool = False,
165
+ ) -> Any:
166
+ """Issues a request, retrying transient failures, and returns the parsed body."""
167
+ url = self._url(path)
168
+ headers = self._headers(method, json is not None, idempotency_key)
169
+ attempt = 0
170
+
171
+ while True:
172
+ status: int | None = None
173
+ retry_after: float | None = None
174
+ error: CosmonerConnectionError | None = None
175
+
176
+ try:
177
+ response = await self._client.request(
178
+ method, url, json=json, params=params, headers=headers
179
+ )
180
+ status = response.status_code
181
+ retry_after = parse_retry_after(response.headers)
182
+
183
+ if response.is_success:
184
+ return self._decode(response)
185
+ except httpx.TimeoutException as err:
186
+ response, error = None, CosmonerTimeoutError(f"Request timed out: {err}")
187
+ except httpx.HTTPError as err:
188
+ response, error = None, CosmonerConnectionError(f"Request failed: {err}")
189
+ else:
190
+ error = None
191
+
192
+ if not self._policy.should_retry(
193
+ attempt=attempt,
194
+ method=method,
195
+ status=status,
196
+ retry_non_idempotent=retry_non_idempotent,
197
+ ):
198
+ if error is not None:
199
+ raise error
200
+ # ``response`` is always set when no transport error was captured.
201
+ assert response is not None
202
+ return self._decode(response) # raises the mapped API error
203
+
204
+ await asyncio.sleep(self._policy.backoff_seconds(attempt, retry_after))
205
+ attempt += 1
206
+
207
+ async def aclose(self) -> None:
208
+ """Closes the underlying connection pool."""
209
+ await self._client.aclose()
@@ -0,0 +1,12 @@
1
+ """Resolves the installed package version for the SDK's User-Agent header."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from importlib import metadata
6
+
7
+ try:
8
+ __version__ = metadata.version("cosmoner-sdk")
9
+ except metadata.PackageNotFoundError: # running from a source checkout
10
+ __version__ = "0.0.0"
11
+
12
+ USER_AGENT = f"cosmoner-python/{__version__}"