spooled 1.0.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.
- spooled/__init__.py +93 -0
- spooled/async_client.py +294 -0
- spooled/client.py +303 -0
- spooled/config.py +146 -0
- spooled/errors.py +327 -0
- spooled/grpc/__init__.py +13 -0
- spooled/grpc/client.py +318 -0
- spooled/py.typed +0 -0
- spooled/realtime/__init__.py +17 -0
- spooled/realtime/events.py +104 -0
- spooled/realtime/sse.py +286 -0
- spooled/realtime/websocket.py +386 -0
- spooled/resources/__init__.py +56 -0
- spooled/resources/admin.py +215 -0
- spooled/resources/api_keys.py +92 -0
- spooled/resources/auth.py +114 -0
- spooled/resources/base.py +27 -0
- spooled/resources/billing.py +56 -0
- spooled/resources/dashboard.py +34 -0
- spooled/resources/health.py +66 -0
- spooled/resources/ingest.py +86 -0
- spooled/resources/jobs.py +319 -0
- spooled/resources/metrics.py +35 -0
- spooled/resources/organizations.py +169 -0
- spooled/resources/queues.py +111 -0
- spooled/resources/schedules.py +152 -0
- spooled/resources/webhooks.py +148 -0
- spooled/resources/workers.py +94 -0
- spooled/resources/workflows.py +166 -0
- spooled/types/__init__.py +273 -0
- spooled/types/admin.py +101 -0
- spooled/types/api_keys.py +72 -0
- spooled/types/auth.py +85 -0
- spooled/types/billing.py +36 -0
- spooled/types/common.py +26 -0
- spooled/types/dashboard.py +70 -0
- spooled/types/health.py +19 -0
- spooled/types/jobs.py +291 -0
- spooled/types/organizations.py +171 -0
- spooled/types/queues.py +79 -0
- spooled/types/schedules.py +111 -0
- spooled/types/webhooks.py +107 -0
- spooled/types/workers.py +80 -0
- spooled/types/workflows.py +114 -0
- spooled/utils/__init__.py +28 -0
- spooled/utils/async_http.py +393 -0
- spooled/utils/casing.py +71 -0
- spooled/utils/circuit_breaker.py +147 -0
- spooled/utils/http.py +368 -0
- spooled/utils/retry.py +152 -0
- spooled/worker/__init__.py +33 -0
- spooled/worker/async_worker.py +509 -0
- spooled/worker/types.py +176 -0
- spooled/worker/worker.py +521 -0
- spooled-1.0.0.dist-info/METADATA +306 -0
- spooled-1.0.0.dist-info/RECORD +58 -0
- spooled-1.0.0.dist-info/WHEEL +4 -0
- spooled-1.0.0.dist-info/licenses/LICENSE +201 -0
spooled/__init__.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Spooled Cloud Python SDK
|
|
3
|
+
|
|
4
|
+
Official Python SDK for the Spooled Cloud job queue service.
|
|
5
|
+
|
|
6
|
+
Example:
|
|
7
|
+
>>> from spooled import SpooledClient
|
|
8
|
+
>>> client = SpooledClient(api_key="sk_live_...")
|
|
9
|
+
>>> result = client.jobs.create(
|
|
10
|
+
... queue_name="emails",
|
|
11
|
+
... payload={"to": "user@example.com"}
|
|
12
|
+
... )
|
|
13
|
+
>>> print(f"Created job: {result.id}")
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from spooled.config import (
|
|
17
|
+
CircuitBreakerConfig,
|
|
18
|
+
RetryConfig,
|
|
19
|
+
SpooledClientConfig,
|
|
20
|
+
)
|
|
21
|
+
from spooled.errors import (
|
|
22
|
+
AuthenticationError,
|
|
23
|
+
AuthorizationError,
|
|
24
|
+
CircuitBreakerOpenError,
|
|
25
|
+
ConflictError,
|
|
26
|
+
JobAbortedError,
|
|
27
|
+
NetworkError,
|
|
28
|
+
NotFoundError,
|
|
29
|
+
PayloadTooLargeError,
|
|
30
|
+
RateLimitError,
|
|
31
|
+
ServerError,
|
|
32
|
+
SpooledError,
|
|
33
|
+
TimeoutError,
|
|
34
|
+
ValidationError,
|
|
35
|
+
is_spooled_error,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
# Version
|
|
39
|
+
__version__ = "1.0.0"
|
|
40
|
+
|
|
41
|
+
# These will be imported when the client modules are created
|
|
42
|
+
__all__ = [
|
|
43
|
+
# Version
|
|
44
|
+
"__version__",
|
|
45
|
+
# Configuration
|
|
46
|
+
"SpooledClientConfig",
|
|
47
|
+
"RetryConfig",
|
|
48
|
+
"CircuitBreakerConfig",
|
|
49
|
+
# Errors
|
|
50
|
+
"SpooledError",
|
|
51
|
+
"AuthenticationError",
|
|
52
|
+
"AuthorizationError",
|
|
53
|
+
"NotFoundError",
|
|
54
|
+
"ConflictError",
|
|
55
|
+
"ValidationError",
|
|
56
|
+
"PayloadTooLargeError",
|
|
57
|
+
"RateLimitError",
|
|
58
|
+
"ServerError",
|
|
59
|
+
"NetworkError",
|
|
60
|
+
"TimeoutError",
|
|
61
|
+
"CircuitBreakerOpenError",
|
|
62
|
+
"JobAbortedError",
|
|
63
|
+
"is_spooled_error",
|
|
64
|
+
# Clients (to be added)
|
|
65
|
+
"SpooledClient",
|
|
66
|
+
"AsyncSpooledClient",
|
|
67
|
+
# Worker (to be added)
|
|
68
|
+
"SpooledWorker",
|
|
69
|
+
"AsyncSpooledWorker",
|
|
70
|
+
]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def __getattr__(name: str):
|
|
74
|
+
"""Lazy import for clients and workers."""
|
|
75
|
+
if name == "SpooledClient":
|
|
76
|
+
from spooled.client import SpooledClient
|
|
77
|
+
|
|
78
|
+
return SpooledClient
|
|
79
|
+
if name == "AsyncSpooledClient":
|
|
80
|
+
from spooled.async_client import AsyncSpooledClient
|
|
81
|
+
|
|
82
|
+
return AsyncSpooledClient
|
|
83
|
+
if name == "SpooledWorker":
|
|
84
|
+
from spooled.worker import SpooledWorker
|
|
85
|
+
|
|
86
|
+
return SpooledWorker
|
|
87
|
+
if name == "AsyncSpooledWorker":
|
|
88
|
+
from spooled.worker import AsyncSpooledWorker
|
|
89
|
+
|
|
90
|
+
return AsyncSpooledWorker
|
|
91
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
92
|
+
|
|
93
|
+
|
spooled/async_client.py
ADDED
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Async Spooled Client
|
|
3
|
+
|
|
4
|
+
Main entry point for the async Spooled SDK.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from spooled.config import (
|
|
12
|
+
ResolvedConfig,
|
|
13
|
+
SpooledClientConfig,
|
|
14
|
+
resolve_config,
|
|
15
|
+
validate_config,
|
|
16
|
+
)
|
|
17
|
+
from spooled.errors import AuthenticationError
|
|
18
|
+
from spooled.resources.admin import AsyncAdminResource
|
|
19
|
+
from spooled.resources.api_keys import AsyncApiKeysResource
|
|
20
|
+
from spooled.resources.auth import AsyncAuthResource
|
|
21
|
+
from spooled.resources.billing import AsyncBillingResource
|
|
22
|
+
from spooled.resources.dashboard import AsyncDashboardResource
|
|
23
|
+
from spooled.resources.health import AsyncHealthResource
|
|
24
|
+
from spooled.resources.ingest import AsyncIngestResource
|
|
25
|
+
from spooled.resources.jobs import AsyncJobsResource
|
|
26
|
+
from spooled.resources.metrics import AsyncMetricsResource
|
|
27
|
+
from spooled.resources.organizations import AsyncOrganizationsResource
|
|
28
|
+
from spooled.resources.queues import AsyncQueuesResource
|
|
29
|
+
from spooled.resources.schedules import AsyncSchedulesResource
|
|
30
|
+
from spooled.resources.webhooks import AsyncWebhooksResource
|
|
31
|
+
from spooled.resources.workers import AsyncWorkersResource
|
|
32
|
+
from spooled.resources.workflows import AsyncWorkflowsResource
|
|
33
|
+
from spooled.utils.async_http import AsyncHttpClient, create_async_http_client
|
|
34
|
+
from spooled.utils.circuit_breaker import CircuitBreaker, create_circuit_breaker
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class AsyncSpooledClient:
|
|
38
|
+
"""
|
|
39
|
+
Spooled Cloud SDK Client (asynchronous).
|
|
40
|
+
|
|
41
|
+
Example:
|
|
42
|
+
>>> from spooled import AsyncSpooledClient
|
|
43
|
+
>>> async with AsyncSpooledClient(api_key="sk_live_...") as client:
|
|
44
|
+
... result = await client.jobs.create({
|
|
45
|
+
... "queue_name": "my-queue",
|
|
46
|
+
... "payload": {"message": "Hello, World!"}
|
|
47
|
+
... })
|
|
48
|
+
... queues = await client.queues.list()
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
def __init__(
|
|
52
|
+
self,
|
|
53
|
+
api_key: str | None = None,
|
|
54
|
+
*,
|
|
55
|
+
access_token: str | None = None,
|
|
56
|
+
refresh_token: str | None = None,
|
|
57
|
+
admin_key: str | None = None,
|
|
58
|
+
base_url: str | None = None,
|
|
59
|
+
timeout: float | None = None,
|
|
60
|
+
debug: bool = False,
|
|
61
|
+
config: SpooledClientConfig | None = None,
|
|
62
|
+
**kwargs: Any,
|
|
63
|
+
) -> None:
|
|
64
|
+
"""
|
|
65
|
+
Initialize the async Spooled client.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
api_key: API key for authentication (sk_live_... or sk_test_...)
|
|
69
|
+
access_token: JWT access token (alternative to api_key)
|
|
70
|
+
refresh_token: JWT refresh token for auto-refresh
|
|
71
|
+
admin_key: Admin key for admin operations
|
|
72
|
+
base_url: API base URL (default: https://api.spooled.cloud)
|
|
73
|
+
timeout: Request timeout in seconds
|
|
74
|
+
debug: Enable debug logging
|
|
75
|
+
config: Full configuration object (overrides other params)
|
|
76
|
+
"""
|
|
77
|
+
# Build config from params or use provided config
|
|
78
|
+
if config is not None:
|
|
79
|
+
self._config = resolve_config(config)
|
|
80
|
+
else:
|
|
81
|
+
config_params: dict[str, Any] = {
|
|
82
|
+
"api_key": api_key,
|
|
83
|
+
"access_token": access_token,
|
|
84
|
+
"refresh_token": refresh_token,
|
|
85
|
+
"admin_key": admin_key,
|
|
86
|
+
"debug": debug,
|
|
87
|
+
**kwargs,
|
|
88
|
+
}
|
|
89
|
+
if base_url is not None:
|
|
90
|
+
config_params["base_url"] = base_url
|
|
91
|
+
if timeout is not None:
|
|
92
|
+
config_params["timeout"] = timeout
|
|
93
|
+
|
|
94
|
+
# Remove None values
|
|
95
|
+
config_params = {k: v for k, v in config_params.items() if v is not None}
|
|
96
|
+
self._config = resolve_config(SpooledClientConfig(**config_params))
|
|
97
|
+
|
|
98
|
+
validate_config(self._config)
|
|
99
|
+
|
|
100
|
+
# Create circuit breaker
|
|
101
|
+
self._circuit_breaker = create_circuit_breaker(self._config.circuit_breaker)
|
|
102
|
+
|
|
103
|
+
# Create HTTP client
|
|
104
|
+
self._http = create_async_http_client(self._config, self._circuit_breaker)
|
|
105
|
+
|
|
106
|
+
# Token refresh state
|
|
107
|
+
self._token_expires_at: float | None = None
|
|
108
|
+
|
|
109
|
+
# Set up token refresh if using JWT
|
|
110
|
+
if (
|
|
111
|
+
self._config.access_token
|
|
112
|
+
and self._config.refresh_token
|
|
113
|
+
and self._config.auto_refresh_token
|
|
114
|
+
):
|
|
115
|
+
self._http.set_refresh_token_fn(self._refresh_access_token)
|
|
116
|
+
|
|
117
|
+
# Create resource instances
|
|
118
|
+
self._auth = AsyncAuthResource(self._http)
|
|
119
|
+
self._jobs = AsyncJobsResource(self._http)
|
|
120
|
+
self._queues = AsyncQueuesResource(self._http)
|
|
121
|
+
self._workers = AsyncWorkersResource(self._http)
|
|
122
|
+
self._schedules = AsyncSchedulesResource(self._http)
|
|
123
|
+
self._workflows = AsyncWorkflowsResource(self._http)
|
|
124
|
+
self._webhooks = AsyncWebhooksResource(self._http)
|
|
125
|
+
self._api_keys = AsyncApiKeysResource(self._http)
|
|
126
|
+
self._organizations = AsyncOrganizationsResource(self._http)
|
|
127
|
+
self._billing = AsyncBillingResource(self._http)
|
|
128
|
+
self._dashboard = AsyncDashboardResource(self._http)
|
|
129
|
+
self._health = AsyncHealthResource(self._http)
|
|
130
|
+
self._metrics = AsyncMetricsResource(self._http)
|
|
131
|
+
self._admin = AsyncAdminResource(self._http, self._config.admin_key)
|
|
132
|
+
self._ingest = AsyncIngestResource(self._http)
|
|
133
|
+
|
|
134
|
+
if self._config.debug_fn:
|
|
135
|
+
self._config.debug_fn(
|
|
136
|
+
"AsyncSpooledClient initialized",
|
|
137
|
+
{
|
|
138
|
+
"base_url": self._config.base_url,
|
|
139
|
+
"has_api_key": bool(self._config.api_key),
|
|
140
|
+
"has_access_token": bool(self._config.access_token),
|
|
141
|
+
},
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
# Resource properties
|
|
145
|
+
|
|
146
|
+
@property
|
|
147
|
+
def auth(self) -> AsyncAuthResource:
|
|
148
|
+
"""Authentication operations."""
|
|
149
|
+
return self._auth
|
|
150
|
+
|
|
151
|
+
@property
|
|
152
|
+
def jobs(self) -> AsyncJobsResource:
|
|
153
|
+
"""Job operations."""
|
|
154
|
+
return self._jobs
|
|
155
|
+
|
|
156
|
+
@property
|
|
157
|
+
def queues(self) -> AsyncQueuesResource:
|
|
158
|
+
"""Queue operations."""
|
|
159
|
+
return self._queues
|
|
160
|
+
|
|
161
|
+
@property
|
|
162
|
+
def workers(self) -> AsyncWorkersResource:
|
|
163
|
+
"""Worker operations."""
|
|
164
|
+
return self._workers
|
|
165
|
+
|
|
166
|
+
@property
|
|
167
|
+
def schedules(self) -> AsyncSchedulesResource:
|
|
168
|
+
"""Schedule operations."""
|
|
169
|
+
return self._schedules
|
|
170
|
+
|
|
171
|
+
@property
|
|
172
|
+
def workflows(self) -> AsyncWorkflowsResource:
|
|
173
|
+
"""Workflow operations."""
|
|
174
|
+
return self._workflows
|
|
175
|
+
|
|
176
|
+
@property
|
|
177
|
+
def webhooks(self) -> AsyncWebhooksResource:
|
|
178
|
+
"""Outgoing webhook operations."""
|
|
179
|
+
return self._webhooks
|
|
180
|
+
|
|
181
|
+
@property
|
|
182
|
+
def api_keys(self) -> AsyncApiKeysResource:
|
|
183
|
+
"""API key operations."""
|
|
184
|
+
return self._api_keys
|
|
185
|
+
|
|
186
|
+
@property
|
|
187
|
+
def organizations(self) -> AsyncOrganizationsResource:
|
|
188
|
+
"""Organization operations."""
|
|
189
|
+
return self._organizations
|
|
190
|
+
|
|
191
|
+
@property
|
|
192
|
+
def billing(self) -> AsyncBillingResource:
|
|
193
|
+
"""Billing operations."""
|
|
194
|
+
return self._billing
|
|
195
|
+
|
|
196
|
+
@property
|
|
197
|
+
def dashboard(self) -> AsyncDashboardResource:
|
|
198
|
+
"""Dashboard operations."""
|
|
199
|
+
return self._dashboard
|
|
200
|
+
|
|
201
|
+
@property
|
|
202
|
+
def health(self) -> AsyncHealthResource:
|
|
203
|
+
"""Health endpoints (public)."""
|
|
204
|
+
return self._health
|
|
205
|
+
|
|
206
|
+
@property
|
|
207
|
+
def metrics(self) -> AsyncMetricsResource:
|
|
208
|
+
"""Metrics endpoint (public)."""
|
|
209
|
+
return self._metrics
|
|
210
|
+
|
|
211
|
+
@property
|
|
212
|
+
def admin(self) -> AsyncAdminResource:
|
|
213
|
+
"""Admin endpoints (requires admin_key)."""
|
|
214
|
+
return self._admin
|
|
215
|
+
|
|
216
|
+
@property
|
|
217
|
+
def ingest(self) -> AsyncIngestResource:
|
|
218
|
+
"""Webhook ingestion endpoints."""
|
|
219
|
+
return self._ingest
|
|
220
|
+
|
|
221
|
+
# Token management
|
|
222
|
+
|
|
223
|
+
async def get_jwt_token(self) -> str:
|
|
224
|
+
"""Get or acquire a JWT token for realtime connections."""
|
|
225
|
+
# If we have an access token, use it
|
|
226
|
+
if self._config.access_token:
|
|
227
|
+
return self._config.access_token
|
|
228
|
+
|
|
229
|
+
# If we only have an API key, exchange it for a JWT
|
|
230
|
+
if self._config.api_key:
|
|
231
|
+
response = await self.auth.login({"api_key": self._config.api_key})
|
|
232
|
+
self._http.set_auth_token(response.access_token)
|
|
233
|
+
# Update config with new tokens
|
|
234
|
+
self._config.access_token = response.access_token
|
|
235
|
+
self._config.refresh_token = response.refresh_token
|
|
236
|
+
return response.access_token
|
|
237
|
+
|
|
238
|
+
raise AuthenticationError("No authentication method available")
|
|
239
|
+
|
|
240
|
+
async def _refresh_access_token(self) -> str:
|
|
241
|
+
"""Refresh the access token."""
|
|
242
|
+
if not self._config.refresh_token:
|
|
243
|
+
raise AuthenticationError("No refresh token available")
|
|
244
|
+
|
|
245
|
+
response = await self.auth.refresh({"refresh_token": self._config.refresh_token})
|
|
246
|
+
self._http.set_auth_token(response.access_token)
|
|
247
|
+
self._config.access_token = response.access_token
|
|
248
|
+
|
|
249
|
+
if self._config.debug_fn:
|
|
250
|
+
self._config.debug_fn("Token refreshed successfully", None)
|
|
251
|
+
|
|
252
|
+
return response.access_token
|
|
253
|
+
|
|
254
|
+
# Configuration access
|
|
255
|
+
|
|
256
|
+
def get_config(self) -> ResolvedConfig:
|
|
257
|
+
"""Get current configuration (read-only)."""
|
|
258
|
+
return self._config
|
|
259
|
+
|
|
260
|
+
def get_circuit_breaker_stats(self) -> dict:
|
|
261
|
+
"""Get circuit breaker statistics."""
|
|
262
|
+
return self._circuit_breaker.get_stats()
|
|
263
|
+
|
|
264
|
+
def reset_circuit_breaker(self) -> None:
|
|
265
|
+
"""Reset the circuit breaker."""
|
|
266
|
+
self._circuit_breaker.reset()
|
|
267
|
+
|
|
268
|
+
# Context manager
|
|
269
|
+
|
|
270
|
+
async def close(self) -> None:
|
|
271
|
+
"""Close the client and release resources."""
|
|
272
|
+
await self._http.close()
|
|
273
|
+
|
|
274
|
+
async def __aenter__(self) -> "AsyncSpooledClient":
|
|
275
|
+
return self
|
|
276
|
+
|
|
277
|
+
async def __aexit__(self, *args: Any) -> None:
|
|
278
|
+
await self.close()
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
async def create_async_client(
|
|
282
|
+
api_key: str | None = None,
|
|
283
|
+
**kwargs: Any,
|
|
284
|
+
) -> AsyncSpooledClient:
|
|
285
|
+
"""
|
|
286
|
+
Create a new AsyncSpooledClient instance.
|
|
287
|
+
|
|
288
|
+
Example:
|
|
289
|
+
>>> from spooled import create_async_client
|
|
290
|
+
>>> client = await create_async_client(api_key="sk_live_...")
|
|
291
|
+
"""
|
|
292
|
+
return AsyncSpooledClient(api_key=api_key, **kwargs)
|
|
293
|
+
|
|
294
|
+
|
spooled/client.py
ADDED
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Spooled Client (synchronous)
|
|
3
|
+
|
|
4
|
+
Main entry point for the Spooled SDK.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from spooled.config import (
|
|
12
|
+
ResolvedConfig,
|
|
13
|
+
SpooledClientConfig,
|
|
14
|
+
resolve_config,
|
|
15
|
+
validate_config,
|
|
16
|
+
)
|
|
17
|
+
from spooled.errors import AuthenticationError
|
|
18
|
+
from spooled.resources.admin import AdminResource
|
|
19
|
+
from spooled.resources.api_keys import ApiKeysResource
|
|
20
|
+
from spooled.resources.auth import AuthResource
|
|
21
|
+
from spooled.resources.billing import BillingResource
|
|
22
|
+
from spooled.resources.dashboard import DashboardResource
|
|
23
|
+
from spooled.resources.health import HealthResource
|
|
24
|
+
from spooled.resources.ingest import IngestResource
|
|
25
|
+
from spooled.resources.jobs import JobsResource
|
|
26
|
+
from spooled.resources.metrics import MetricsResource
|
|
27
|
+
from spooled.resources.organizations import OrganizationsResource
|
|
28
|
+
from spooled.resources.queues import QueuesResource
|
|
29
|
+
from spooled.resources.schedules import SchedulesResource
|
|
30
|
+
from spooled.resources.webhooks import WebhooksResource
|
|
31
|
+
from spooled.resources.workers import WorkersResource
|
|
32
|
+
from spooled.resources.workflows import WorkflowsResource
|
|
33
|
+
from spooled.utils.circuit_breaker import CircuitBreaker, create_circuit_breaker
|
|
34
|
+
from spooled.utils.http import HttpClient, create_http_client
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class SpooledClient:
|
|
38
|
+
"""
|
|
39
|
+
Spooled Cloud SDK Client (synchronous).
|
|
40
|
+
|
|
41
|
+
Example:
|
|
42
|
+
>>> from spooled import SpooledClient
|
|
43
|
+
>>> client = SpooledClient(api_key="sk_live_...")
|
|
44
|
+
>>>
|
|
45
|
+
>>> # Create a job
|
|
46
|
+
>>> result = client.jobs.create({
|
|
47
|
+
... "queue_name": "my-queue",
|
|
48
|
+
... "payload": {"message": "Hello, World!"}
|
|
49
|
+
... })
|
|
50
|
+
>>>
|
|
51
|
+
>>> # List queues
|
|
52
|
+
>>> queues = client.queues.list()
|
|
53
|
+
|
|
54
|
+
Using as context manager:
|
|
55
|
+
>>> with SpooledClient(api_key="sk_live_...") as client:
|
|
56
|
+
... job = client.jobs.create({"queue_name": "test", "payload": {}})
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
def __init__(
|
|
60
|
+
self,
|
|
61
|
+
api_key: str | None = None,
|
|
62
|
+
*,
|
|
63
|
+
access_token: str | None = None,
|
|
64
|
+
refresh_token: str | None = None,
|
|
65
|
+
admin_key: str | None = None,
|
|
66
|
+
base_url: str | None = None,
|
|
67
|
+
timeout: float | None = None,
|
|
68
|
+
debug: bool = False,
|
|
69
|
+
config: SpooledClientConfig | None = None,
|
|
70
|
+
**kwargs: Any,
|
|
71
|
+
) -> None:
|
|
72
|
+
"""
|
|
73
|
+
Initialize the Spooled client.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
api_key: API key for authentication (sk_live_... or sk_test_...)
|
|
77
|
+
access_token: JWT access token (alternative to api_key)
|
|
78
|
+
refresh_token: JWT refresh token for auto-refresh
|
|
79
|
+
admin_key: Admin key for admin operations
|
|
80
|
+
base_url: API base URL (default: https://api.spooled.cloud)
|
|
81
|
+
timeout: Request timeout in seconds
|
|
82
|
+
debug: Enable debug logging
|
|
83
|
+
config: Full configuration object (overrides other params)
|
|
84
|
+
"""
|
|
85
|
+
# Build config from params or use provided config
|
|
86
|
+
if config is not None:
|
|
87
|
+
self._config = resolve_config(config)
|
|
88
|
+
else:
|
|
89
|
+
config_params: dict[str, Any] = {
|
|
90
|
+
"api_key": api_key,
|
|
91
|
+
"access_token": access_token,
|
|
92
|
+
"refresh_token": refresh_token,
|
|
93
|
+
"admin_key": admin_key,
|
|
94
|
+
"debug": debug,
|
|
95
|
+
**kwargs,
|
|
96
|
+
}
|
|
97
|
+
if base_url is not None:
|
|
98
|
+
config_params["base_url"] = base_url
|
|
99
|
+
if timeout is not None:
|
|
100
|
+
config_params["timeout"] = timeout
|
|
101
|
+
|
|
102
|
+
# Remove None values
|
|
103
|
+
config_params = {k: v for k, v in config_params.items() if v is not None}
|
|
104
|
+
self._config = resolve_config(SpooledClientConfig(**config_params))
|
|
105
|
+
|
|
106
|
+
validate_config(self._config)
|
|
107
|
+
|
|
108
|
+
# Create circuit breaker
|
|
109
|
+
self._circuit_breaker = create_circuit_breaker(self._config.circuit_breaker)
|
|
110
|
+
|
|
111
|
+
# Create HTTP client
|
|
112
|
+
self._http = create_http_client(self._config, self._circuit_breaker)
|
|
113
|
+
|
|
114
|
+
# Token refresh state
|
|
115
|
+
self._refresh_promise: str | None = None
|
|
116
|
+
self._token_expires_at: float | None = None
|
|
117
|
+
|
|
118
|
+
# Set up token refresh if using JWT
|
|
119
|
+
if (
|
|
120
|
+
self._config.access_token
|
|
121
|
+
and self._config.refresh_token
|
|
122
|
+
and self._config.auto_refresh_token
|
|
123
|
+
):
|
|
124
|
+
self._http.set_refresh_token_fn(self._refresh_access_token)
|
|
125
|
+
|
|
126
|
+
# Create resource instances
|
|
127
|
+
self._auth = AuthResource(self._http)
|
|
128
|
+
self._jobs = JobsResource(self._http)
|
|
129
|
+
self._queues = QueuesResource(self._http)
|
|
130
|
+
self._workers = WorkersResource(self._http)
|
|
131
|
+
self._schedules = SchedulesResource(self._http)
|
|
132
|
+
self._workflows = WorkflowsResource(self._http)
|
|
133
|
+
self._webhooks = WebhooksResource(self._http)
|
|
134
|
+
self._api_keys = ApiKeysResource(self._http)
|
|
135
|
+
self._organizations = OrganizationsResource(self._http)
|
|
136
|
+
self._billing = BillingResource(self._http)
|
|
137
|
+
self._dashboard = DashboardResource(self._http)
|
|
138
|
+
self._health = HealthResource(self._http)
|
|
139
|
+
self._metrics = MetricsResource(self._http)
|
|
140
|
+
self._admin = AdminResource(self._http, self._config.admin_key)
|
|
141
|
+
self._ingest = IngestResource(self._http)
|
|
142
|
+
|
|
143
|
+
if self._config.debug_fn:
|
|
144
|
+
self._config.debug_fn(
|
|
145
|
+
"SpooledClient initialized",
|
|
146
|
+
{
|
|
147
|
+
"base_url": self._config.base_url,
|
|
148
|
+
"has_api_key": bool(self._config.api_key),
|
|
149
|
+
"has_access_token": bool(self._config.access_token),
|
|
150
|
+
},
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
# Resource properties
|
|
154
|
+
|
|
155
|
+
@property
|
|
156
|
+
def auth(self) -> AuthResource:
|
|
157
|
+
"""Authentication operations."""
|
|
158
|
+
return self._auth
|
|
159
|
+
|
|
160
|
+
@property
|
|
161
|
+
def jobs(self) -> JobsResource:
|
|
162
|
+
"""Job operations."""
|
|
163
|
+
return self._jobs
|
|
164
|
+
|
|
165
|
+
@property
|
|
166
|
+
def queues(self) -> QueuesResource:
|
|
167
|
+
"""Queue operations."""
|
|
168
|
+
return self._queues
|
|
169
|
+
|
|
170
|
+
@property
|
|
171
|
+
def workers(self) -> WorkersResource:
|
|
172
|
+
"""Worker operations."""
|
|
173
|
+
return self._workers
|
|
174
|
+
|
|
175
|
+
@property
|
|
176
|
+
def schedules(self) -> SchedulesResource:
|
|
177
|
+
"""Schedule operations."""
|
|
178
|
+
return self._schedules
|
|
179
|
+
|
|
180
|
+
@property
|
|
181
|
+
def workflows(self) -> WorkflowsResource:
|
|
182
|
+
"""Workflow operations."""
|
|
183
|
+
return self._workflows
|
|
184
|
+
|
|
185
|
+
@property
|
|
186
|
+
def webhooks(self) -> WebhooksResource:
|
|
187
|
+
"""Outgoing webhook operations."""
|
|
188
|
+
return self._webhooks
|
|
189
|
+
|
|
190
|
+
@property
|
|
191
|
+
def api_keys(self) -> ApiKeysResource:
|
|
192
|
+
"""API key operations."""
|
|
193
|
+
return self._api_keys
|
|
194
|
+
|
|
195
|
+
@property
|
|
196
|
+
def organizations(self) -> OrganizationsResource:
|
|
197
|
+
"""Organization operations."""
|
|
198
|
+
return self._organizations
|
|
199
|
+
|
|
200
|
+
@property
|
|
201
|
+
def billing(self) -> BillingResource:
|
|
202
|
+
"""Billing operations."""
|
|
203
|
+
return self._billing
|
|
204
|
+
|
|
205
|
+
@property
|
|
206
|
+
def dashboard(self) -> DashboardResource:
|
|
207
|
+
"""Dashboard operations."""
|
|
208
|
+
return self._dashboard
|
|
209
|
+
|
|
210
|
+
@property
|
|
211
|
+
def health(self) -> HealthResource:
|
|
212
|
+
"""Health endpoints (public)."""
|
|
213
|
+
return self._health
|
|
214
|
+
|
|
215
|
+
@property
|
|
216
|
+
def metrics(self) -> MetricsResource:
|
|
217
|
+
"""Metrics endpoint (public)."""
|
|
218
|
+
return self._metrics
|
|
219
|
+
|
|
220
|
+
@property
|
|
221
|
+
def admin(self) -> AdminResource:
|
|
222
|
+
"""Admin endpoints (requires admin_key)."""
|
|
223
|
+
return self._admin
|
|
224
|
+
|
|
225
|
+
@property
|
|
226
|
+
def ingest(self) -> IngestResource:
|
|
227
|
+
"""Webhook ingestion endpoints."""
|
|
228
|
+
return self._ingest
|
|
229
|
+
|
|
230
|
+
# Token management
|
|
231
|
+
|
|
232
|
+
def get_jwt_token(self) -> str:
|
|
233
|
+
"""Get or acquire a JWT token for realtime connections."""
|
|
234
|
+
# If we have an access token, use it
|
|
235
|
+
if self._config.access_token:
|
|
236
|
+
return self._config.access_token
|
|
237
|
+
|
|
238
|
+
# If we only have an API key, exchange it for a JWT
|
|
239
|
+
if self._config.api_key:
|
|
240
|
+
response = self.auth.login({"api_key": self._config.api_key})
|
|
241
|
+
self._http.set_auth_token(response.access_token)
|
|
242
|
+
# Update config with new tokens
|
|
243
|
+
self._config.access_token = response.access_token
|
|
244
|
+
self._config.refresh_token = response.refresh_token
|
|
245
|
+
return response.access_token
|
|
246
|
+
|
|
247
|
+
raise AuthenticationError("No authentication method available")
|
|
248
|
+
|
|
249
|
+
def _refresh_access_token(self) -> str:
|
|
250
|
+
"""Refresh the access token."""
|
|
251
|
+
if not self._config.refresh_token:
|
|
252
|
+
raise AuthenticationError("No refresh token available")
|
|
253
|
+
|
|
254
|
+
response = self.auth.refresh({"refresh_token": self._config.refresh_token})
|
|
255
|
+
self._http.set_auth_token(response.access_token)
|
|
256
|
+
self._config.access_token = response.access_token
|
|
257
|
+
|
|
258
|
+
if self._config.debug_fn:
|
|
259
|
+
self._config.debug_fn("Token refreshed successfully", None)
|
|
260
|
+
|
|
261
|
+
return response.access_token
|
|
262
|
+
|
|
263
|
+
# Configuration access
|
|
264
|
+
|
|
265
|
+
def get_config(self) -> ResolvedConfig:
|
|
266
|
+
"""Get current configuration (read-only)."""
|
|
267
|
+
return self._config
|
|
268
|
+
|
|
269
|
+
def get_circuit_breaker_stats(self) -> dict:
|
|
270
|
+
"""Get circuit breaker statistics."""
|
|
271
|
+
return self._circuit_breaker.get_stats()
|
|
272
|
+
|
|
273
|
+
def reset_circuit_breaker(self) -> None:
|
|
274
|
+
"""Reset the circuit breaker."""
|
|
275
|
+
self._circuit_breaker.reset()
|
|
276
|
+
|
|
277
|
+
# Context manager
|
|
278
|
+
|
|
279
|
+
def close(self) -> None:
|
|
280
|
+
"""Close the client and release resources."""
|
|
281
|
+
self._http.close()
|
|
282
|
+
|
|
283
|
+
def __enter__(self) -> "SpooledClient":
|
|
284
|
+
return self
|
|
285
|
+
|
|
286
|
+
def __exit__(self, *args: Any) -> None:
|
|
287
|
+
self.close()
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def create_client(
|
|
291
|
+
api_key: str | None = None,
|
|
292
|
+
**kwargs: Any,
|
|
293
|
+
) -> SpooledClient:
|
|
294
|
+
"""
|
|
295
|
+
Create a new SpooledClient instance.
|
|
296
|
+
|
|
297
|
+
Example:
|
|
298
|
+
>>> from spooled import create_client
|
|
299
|
+
>>> client = create_client(api_key="sk_live_...")
|
|
300
|
+
"""
|
|
301
|
+
return SpooledClient(api_key=api_key, **kwargs)
|
|
302
|
+
|
|
303
|
+
|