workhold-consumer 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.
@@ -0,0 +1,95 @@
1
+ """Public consumer client distribution.
2
+
3
+ Thin role surface over shared ``_workhold_client_core`` primitives.
4
+ Operation ownership: getCapabilities, claimTasks, heartbeatClaim, completeClaim,
5
+ failClaim, acknowledgeClaimCancellation
6
+ (see ``packages/client-operation-ownership.json``).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from _workhold_client_core.capabilities import Capabilities
12
+ from _workhold_client_core.codecs import (
13
+ PayloadDecodeError,
14
+ PayloadDecoder,
15
+ PayloadEncoder,
16
+ TypedClaimView,
17
+ TypedTaskView,
18
+ decode_payload,
19
+ encode_payload,
20
+ )
21
+ from _workhold_client_core.errors import (
22
+ AuthenticationError,
23
+ ErrorCode,
24
+ LeaseLostError,
25
+ MalformedResponseError,
26
+ ProtocolError,
27
+ QueueClientError,
28
+ RequestCancelledError,
29
+ TerminalConflictError,
30
+ TimeoutError,
31
+ TransportError,
32
+ )
33
+ from _workhold_client_core.models import (
34
+ AckCancelResult,
35
+ ClaimSummary,
36
+ CompleteResult,
37
+ FailResult,
38
+ HeartbeatResult,
39
+ ProtocolErrorBody,
40
+ Task,
41
+ TaskState,
42
+ )
43
+ from _workhold_client_core.transport import HttpJsonTransport, encode_path_segment
44
+ from workhold_consumer.client import Claim, ConsumerClient
45
+ from workhold_consumer.supervisor import (
46
+ DEFAULT_WAIT_SECONDS,
47
+ HEARTBEAT_JITTER_RATIO,
48
+ CancellationToken,
49
+ ConsumerSupervisor,
50
+ HandlerErrorEvent,
51
+ LeaseLostEvent,
52
+ )
53
+
54
+ __version__ = "1.0.0"
55
+
56
+
57
+ __all__ = [
58
+ "AckCancelResult",
59
+ "AuthenticationError",
60
+ "CancellationToken",
61
+ "Capabilities",
62
+ "Claim",
63
+ "ClaimSummary",
64
+ "CompleteResult",
65
+ "ConsumerClient",
66
+ "ConsumerSupervisor",
67
+ "DEFAULT_WAIT_SECONDS",
68
+ "ErrorCode",
69
+ "FailResult",
70
+ "HandlerErrorEvent",
71
+ "HEARTBEAT_JITTER_RATIO",
72
+ "HeartbeatResult",
73
+ "HttpJsonTransport",
74
+ "LeaseLostError",
75
+ "LeaseLostEvent",
76
+ "MalformedResponseError",
77
+ "PayloadDecodeError",
78
+ "PayloadDecoder",
79
+ "PayloadEncoder",
80
+ "ProtocolError",
81
+ "ProtocolErrorBody",
82
+ "QueueClientError",
83
+ "RequestCancelledError",
84
+ "Task",
85
+ "TaskState",
86
+ "TerminalConflictError",
87
+ "TimeoutError",
88
+ "TransportError",
89
+ "TypedClaimView",
90
+ "TypedTaskView",
91
+ "decode_payload",
92
+ "encode_path_segment",
93
+ "encode_payload",
94
+ "__version__",
95
+ ]
@@ -0,0 +1,477 @@
1
+ """Async consumer SDK adapter over the OpenAPI worker-authorized lease surface."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from collections.abc import Mapping, Sequence
7
+ from typing import Any
8
+
9
+ from _workhold_client_core.async_transport import AsyncTransport
10
+ from _workhold_client_core.capabilities import Capabilities
11
+ from _workhold_client_core.capability_guard import (
12
+ require_delivery_events,
13
+ require_long_polling,
14
+ )
15
+ from _workhold_client_core.codecs import (
16
+ PayloadDecoder,
17
+ PayloadEncoder,
18
+ )
19
+ from _workhold_client_core.errors import (
20
+ LeaseLostError,
21
+ MalformedResponseError,
22
+ ProtocolError,
23
+ TerminalConflictError,
24
+ TimeoutError as ClientTimeoutError,
25
+ )
26
+ from _workhold_client_core.instrumentation import observe_request
27
+ from _workhold_client_core.models import (
28
+ AckCancelResult,
29
+ CompleteResult,
30
+ FailResult,
31
+ HeartbeatResult,
32
+ Task,
33
+ )
34
+ from _workhold_client_core.transport import encode_path_segment
35
+
36
+ from workhold_consumer.client import (
37
+ _CLAIM_TOKEN_HEADER,
38
+ _encode_spawn_items,
39
+ _fingerprint,
40
+ _require_bool,
41
+ _require_int,
42
+ _require_mapping,
43
+ _require_str,
44
+ )
45
+
46
+
47
+ class AsyncConsumerClient:
48
+ """Async consumer operations mirroring :class:`ConsumerClient`.
49
+
50
+ Positive ``wait_seconds`` uses the same capability gate and per-call
51
+ ``wait+5`` / ``wait+10`` budgets as sync. ``asyncio.CancelledError``
52
+ propagates unchanged and is never wrapped as timeout/protocol error.
53
+ """
54
+
55
+ def __init__(
56
+ self,
57
+ transport: AsyncTransport,
58
+ *,
59
+ bearer_token: str,
60
+ owns_transport: bool = False,
61
+ ) -> None:
62
+ if not bearer_token or not bearer_token.strip():
63
+ raise ValueError("bearer_token is required")
64
+ self._transport = transport
65
+ self._bearer_token = bearer_token
66
+ self._owns_transport = owns_transport
67
+
68
+ @classmethod
69
+ def from_url(
70
+ cls,
71
+ base_url: str,
72
+ *,
73
+ bearer_token: str,
74
+ timeout_s: float = 30.0,
75
+ ) -> AsyncConsumerClient:
76
+ from _workhold_client_core.async_transport import HttpxAsyncTransport
77
+
78
+ transport = HttpxAsyncTransport(base_url, timeout_s=timeout_s)
79
+ return cls(transport, bearer_token=bearer_token, owns_transport=True)
80
+
81
+ async def __aenter__(self) -> AsyncConsumerClient:
82
+ return self
83
+
84
+ async def __aexit__(self, *_exc: object) -> None:
85
+ if self._owns_transport:
86
+ await self._transport.aclose()
87
+
88
+ def __repr__(self) -> str:
89
+ return "AsyncConsumerClient(transport=..., bearer_token=<redacted>)"
90
+
91
+ def _auth_headers(self, extra: dict[str, str] | None = None) -> dict[str, str]:
92
+ headers = {"Authorization": f"Bearer {self._bearer_token}"}
93
+ if extra:
94
+ headers.update(extra)
95
+ return headers
96
+
97
+ async def get_capabilities(self) -> Capabilities:
98
+ response = await self._transport.request(
99
+ "GET",
100
+ "/v1/capabilities",
101
+ headers=self._auth_headers(),
102
+ json_body=None,
103
+ )
104
+ return self._parse(Capabilities.parse, response.status_code, response.body)
105
+
106
+ async def claim(
107
+ self,
108
+ *,
109
+ queues: Sequence[str],
110
+ worker_id: str,
111
+ lease_seconds: int,
112
+ max_tasks: int = 1,
113
+ wait_seconds: int = 0,
114
+ capabilities: Capabilities | None = None,
115
+ cancellation: object | None = None,
116
+ payload_decoder: PayloadDecoder[Any] | None = None,
117
+ ) -> list[AsyncClaim]:
118
+ """POST ``/v1/claims`` (OpenAPI ``claimTasks``).
119
+
120
+ MVP locks ``max_tasks`` to exact ``1`` locally before any capability
121
+ fetch or transport call. Positive ``wait_seconds`` still requires
122
+ authenticated live long-polling capabilities (fail-closed). Zero wait
123
+ bypasses the long-poll feature guard. Empty ``tasks: []`` is success
124
+ (including long-poll expiry). ``asyncio.CancelledError`` propagates
125
+ unchanged.
126
+ """
127
+
128
+ if isinstance(max_tasks, bool) or type(max_tasks) is not int:
129
+ raise ValueError("max_tasks must be an integer")
130
+ if max_tasks != 1:
131
+ raise ValueError("MVP claim requires max_tasks=1")
132
+ if isinstance(wait_seconds, bool) or type(wait_seconds) is not int:
133
+ raise ValueError("wait_seconds must be an integer")
134
+ if wait_seconds < 0:
135
+ raise ValueError("wait_seconds must be >= 0")
136
+ if not queues:
137
+ raise ValueError("queues must be non-empty")
138
+ if not worker_id:
139
+ raise ValueError("worker_id is required")
140
+
141
+ if wait_seconds > 0:
142
+ caps = (
143
+ capabilities
144
+ if capabilities is not None
145
+ else await self.get_capabilities()
146
+ )
147
+ require_long_polling(caps, wait_seconds)
148
+
149
+ read_timeout_s: float | None = None
150
+ total_timeout_s: float | None = None
151
+ if wait_seconds > 0:
152
+ config = getattr(self._transport, "config", None)
153
+ if config is None:
154
+ raise ValueError(
155
+ "positive wait_seconds requires transport.config for long-poll budgets"
156
+ )
157
+ read_timeout_s, total_timeout_s = config.ensure_long_poll_budgets(wait_seconds)
158
+
159
+ body = {
160
+ "queues": list(queues),
161
+ "max_tasks": 1,
162
+ "lease_seconds": lease_seconds,
163
+ "wait_seconds": wait_seconds,
164
+ "worker_id": worker_id,
165
+ }
166
+ try:
167
+ response = await self._transport.request(
168
+ "POST",
169
+ "/v1/claims",
170
+ headers=self._auth_headers(),
171
+ json_body=body,
172
+ read_timeout_s=read_timeout_s,
173
+ total_timeout_s=total_timeout_s,
174
+ cancellation=cancellation,
175
+ )
176
+ except asyncio.CancelledError:
177
+ observe_request(None, operation="claimTasks", result="cancelled", duration_s=0.0)
178
+ raise
179
+ except ClientTimeoutError:
180
+ observe_request(None, operation="claimTasks", result="timeout", duration_s=0.0)
181
+ raise
182
+
183
+ payload = _require_mapping(response.status_code, response.body)
184
+ try:
185
+ tasks_raw = payload["tasks"]
186
+ server_time = _require_str(payload["server_time"], "server_time")
187
+ recommended = _require_int(
188
+ payload["recommended_heartbeat_seconds"],
189
+ "recommended_heartbeat_seconds",
190
+ )
191
+ if "queue_states" not in payload:
192
+ raise ValueError("claim response missing queue_states")
193
+ if not isinstance(payload["queue_states"], Mapping):
194
+ raise ValueError("claim response queue_states must be an object")
195
+ except KeyError as exc:
196
+ raise MalformedResponseError(
197
+ status_code=response.status_code,
198
+ reason=f"claim response missing field: {exc.args[0]}",
199
+ ) from exc
200
+ except ValueError as exc:
201
+ raise MalformedResponseError(
202
+ status_code=response.status_code,
203
+ reason=str(exc),
204
+ ) from exc
205
+
206
+ if not isinstance(tasks_raw, list):
207
+ raise MalformedResponseError(
208
+ status_code=response.status_code,
209
+ reason="claim response tasks must be an array",
210
+ )
211
+ if not tasks_raw:
212
+ observe_request(None, operation="claimTasks", result="empty", duration_s=0.0)
213
+ return []
214
+ if len(tasks_raw) > max_tasks:
215
+ raise MalformedResponseError(
216
+ status_code=response.status_code,
217
+ reason=(
218
+ f"claim response returned {len(tasks_raw)} tasks, "
219
+ f"max allowed is {max_tasks}"
220
+ ),
221
+ )
222
+
223
+ claims: list[AsyncClaim] = []
224
+ for item in tasks_raw:
225
+ try:
226
+ claims.append(
227
+ AsyncClaim.from_claimed_task(
228
+ self,
229
+ item,
230
+ server_time=server_time,
231
+ recommended_heartbeat_seconds=recommended,
232
+ payload_decoder=payload_decoder,
233
+ )
234
+ )
235
+ except ValueError as exc:
236
+ raise MalformedResponseError(
237
+ status_code=response.status_code,
238
+ reason=str(exc),
239
+ ) from exc
240
+ observe_request(None, operation="claimTasks", result="success", duration_s=0.0)
241
+ return claims
242
+
243
+ @staticmethod
244
+ def _parse(parser: Any, status_code: int, body: object | None) -> Any:
245
+ if body is None:
246
+ raise MalformedResponseError(
247
+ status_code=status_code,
248
+ reason="empty success response body",
249
+ )
250
+ try:
251
+ return parser(body)
252
+ except ValueError as exc:
253
+ raise MalformedResponseError(
254
+ status_code=status_code,
255
+ reason=str(exc),
256
+ ) from exc
257
+
258
+
259
+ class AsyncClaim:
260
+ """Local fenced-lease handle for one claimed task (async)."""
261
+
262
+ def __init__(
263
+ self,
264
+ client: AsyncConsumerClient,
265
+ *,
266
+ task: Task,
267
+ claim_id: str,
268
+ generation: int,
269
+ claimed_at: str,
270
+ lease_expires_at: str,
271
+ worker_id: str,
272
+ cancel_requested: bool,
273
+ claim_token: str,
274
+ server_time: str,
275
+ recommended_heartbeat_seconds: int,
276
+ payload_decoder: PayloadDecoder[Any] | None = None,
277
+ ) -> None:
278
+ self._client = client
279
+ self.task = task
280
+ self.claim_id = claim_id
281
+ self.generation = generation
282
+ self.claimed_at = claimed_at
283
+ self.lease_expires_at = lease_expires_at
284
+ self.worker_id = worker_id
285
+ self.cancel_requested = cancel_requested
286
+ self.server_time = server_time
287
+ self.recommended_heartbeat_seconds = recommended_heartbeat_seconds
288
+ self.payload_decoder = payload_decoder
289
+ self._claim_token = claim_token
290
+ self._lease_lost = False
291
+ self._terminal: tuple[str, str] | None = None
292
+
293
+ @classmethod
294
+ def from_claimed_task(
295
+ cls,
296
+ client: AsyncConsumerClient,
297
+ raw: object,
298
+ *,
299
+ server_time: str,
300
+ recommended_heartbeat_seconds: int,
301
+ payload_decoder: PayloadDecoder[Any] | None = None,
302
+ ) -> AsyncClaim:
303
+ if not isinstance(raw, Mapping):
304
+ raise ValueError("claimed task must be an object")
305
+ if "task" not in raw or "claim" not in raw:
306
+ raise ValueError("claimed task requires task and claim")
307
+ claim_raw = raw["claim"]
308
+ if not isinstance(claim_raw, Mapping):
309
+ raise ValueError("claim grant must be an object")
310
+ required = (
311
+ "claim_id",
312
+ "generation",
313
+ "claimed_at",
314
+ "lease_expires_at",
315
+ "worker_id",
316
+ "cancel_requested",
317
+ "claim_token",
318
+ )
319
+ missing = [key for key in required if key not in claim_raw]
320
+ if missing:
321
+ raise ValueError(f"claim grant missing fields: {missing}")
322
+ return cls(
323
+ client,
324
+ task=Task.parse(raw["task"]),
325
+ claim_id=_require_str(claim_raw["claim_id"], "claim.claim_id"),
326
+ generation=_require_int(claim_raw["generation"], "claim.generation"),
327
+ claimed_at=_require_str(claim_raw["claimed_at"], "claim.claimed_at"),
328
+ lease_expires_at=_require_str(
329
+ claim_raw["lease_expires_at"], "claim.lease_expires_at"
330
+ ),
331
+ worker_id=_require_str(claim_raw["worker_id"], "claim.worker_id"),
332
+ cancel_requested=_require_bool(
333
+ claim_raw["cancel_requested"], "claim.cancel_requested"
334
+ ),
335
+ claim_token=_require_str(claim_raw["claim_token"], "claim.claim_token"),
336
+ server_time=server_time,
337
+ recommended_heartbeat_seconds=recommended_heartbeat_seconds,
338
+ payload_decoder=payload_decoder,
339
+ )
340
+
341
+ @property
342
+ def lease_lost(self) -> bool:
343
+ return self._lease_lost
344
+
345
+ @property
346
+ def is_terminal(self) -> bool:
347
+ return self._terminal is not None
348
+
349
+ def __repr__(self) -> str:
350
+ return (
351
+ f"AsyncClaim(claim_id={self.claim_id!r}, generation={self.generation!r}, "
352
+ f"lease_lost={self._lease_lost!r})"
353
+ )
354
+
355
+ def __str__(self) -> str:
356
+ return self.__repr__()
357
+
358
+ async def heartbeat(self, *, lease_seconds: int) -> HeartbeatResult:
359
+ self._ensure_usable()
360
+ body = {"generation": self.generation, "lease_seconds": lease_seconds}
361
+ result = await self._request_lease("heartbeat", body, HeartbeatResult.parse)
362
+ self.lease_expires_at = result.claim.lease_expires_at
363
+ self.cancel_requested = result.claim.cancel_requested
364
+ self.server_time = result.server_time
365
+ self.recommended_heartbeat_seconds = result.recommended_heartbeat_seconds
366
+ return result
367
+
368
+ async def complete(
369
+ self,
370
+ *,
371
+ spawn: Sequence[Mapping[str, Any]] | None = None,
372
+ events: Sequence[Mapping[str, Any]] | None = None,
373
+ capabilities: Capabilities | None = None,
374
+ spawn_encoder: PayloadEncoder[Any] | None = None,
375
+ max_payload_bytes: int | None = None,
376
+ ) -> CompleteResult:
377
+ self._ensure_usable()
378
+ limit = max_payload_bytes
379
+ if limit is None and capabilities is not None:
380
+ limit = capabilities.payload_runtime_max_bytes
381
+ wire_spawn = _encode_spawn_items(
382
+ spawn, encoder=spawn_encoder, max_payload_bytes=limit
383
+ )
384
+ if events:
385
+ caps = (
386
+ capabilities
387
+ if capabilities is not None
388
+ else await self._client.get_capabilities()
389
+ )
390
+ require_delivery_events(caps, events)
391
+ body: dict[str, Any] = {
392
+ "generation": self.generation,
393
+ "spawn": wire_spawn,
394
+ }
395
+ if events:
396
+ body["events"] = [dict(item) for item in events]
397
+ fingerprint = self._check_terminal("complete", body)
398
+ result = await self._request_lease("complete", body, CompleteResult.parse)
399
+ self._terminal = ("complete", fingerprint)
400
+ return result
401
+
402
+ async def fail(
403
+ self,
404
+ *,
405
+ retryable: bool,
406
+ failure_code: str,
407
+ failure_detail: str | None = None,
408
+ ) -> FailResult:
409
+ self._ensure_usable()
410
+ body: dict[str, Any] = {
411
+ "generation": self.generation,
412
+ "retryable": retryable,
413
+ "failure_code": failure_code,
414
+ }
415
+ if failure_detail is not None:
416
+ body["failure_detail"] = failure_detail
417
+ fingerprint = self._check_terminal("fail", body)
418
+ result = await self._request_lease("fail", body, FailResult.parse)
419
+ self._terminal = ("fail", fingerprint)
420
+ return result
421
+
422
+ async def ack_cancel(self) -> AckCancelResult:
423
+ self._ensure_usable()
424
+ body = {"generation": self.generation}
425
+ fingerprint = self._check_terminal("ack_cancel", body)
426
+ result = await self._request_lease("ack-cancel", body, AckCancelResult.parse)
427
+ self._terminal = ("ack_cancel", fingerprint)
428
+ return result
429
+
430
+ def _ensure_usable(self) -> None:
431
+ if self._lease_lost:
432
+ raise LeaseLostError(claim_id=self.claim_id)
433
+
434
+ def _check_terminal(self, kind: str, body: Mapping[str, Any]) -> str:
435
+ fingerprint = _fingerprint(body)
436
+ if self._terminal is None:
437
+ return fingerprint
438
+ prev_kind, prev_fp = self._terminal
439
+ if prev_kind == kind and prev_fp == fingerprint:
440
+ return fingerprint
441
+ raise TerminalConflictError(
442
+ claim_id=self.claim_id,
443
+ reason="changed terminal body or operation",
444
+ )
445
+
446
+ def _lease_headers(self) -> dict[str, str]:
447
+ return self._client._auth_headers({_CLAIM_TOKEN_HEADER: self._claim_token})
448
+
449
+ async def _request_lease(
450
+ self,
451
+ operation: str,
452
+ body: Mapping[str, Any],
453
+ parser: Any,
454
+ ) -> Any:
455
+ path = f"/v1/claims/{encode_path_segment(self.claim_id)}:{operation}"
456
+ try:
457
+ response = await self._client._transport.request(
458
+ "POST",
459
+ path,
460
+ headers=self._lease_headers(),
461
+ json_body=dict(body),
462
+ )
463
+ except ProtocolError as exc:
464
+ if exc.code.value == "lease_lost" and self._terminal is None:
465
+ self._lease_lost = True
466
+ raise
467
+ payload = _require_mapping(response.status_code, response.body)
468
+ try:
469
+ return parser(payload)
470
+ except ValueError as exc:
471
+ raise MalformedResponseError(
472
+ status_code=response.status_code,
473
+ reason=str(exc),
474
+ ) from exc
475
+
476
+
477
+ __all__ = ["AsyncClaim", "AsyncConsumerClient"]