workhold-admin 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,140 @@
1
+ """Public admin/observer/break-glass client distribution.
2
+
3
+ Thin role surfaces over shared ``_workhold_client_core`` primitives.
4
+ Observer operations: getCapabilities, getTask, listTaskAttempts, getQueue,
5
+ getStats, getMaintenanceStatus, listInspectionTasks, listInspectionAttempts,
6
+ listDeadLetters (see ``packages/client-operation-ownership.json``).
7
+ Admin audit/maintenance: listAdminAudit, getMaintenanceStatus, runMaintenance.
8
+ Admin recovery: replayDeadLetter, preview/execute bulk replay/cancel.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from _workhold_client_core.capabilities import Capabilities
14
+ from _workhold_client_core.errors import (
15
+ AuthenticationError,
16
+ MalformedResponseError,
17
+ ProtocolError,
18
+ QueueClientError,
19
+ TimeoutError,
20
+ TransportError,
21
+ )
22
+ from _workhold_client_core.models import Task, TaskState
23
+ from _workhold_client_core.transport import HttpJsonTransport, encode_path_segment
24
+ from workhold_admin.admin import AdminClient
25
+ from workhold_admin.break_glass import BreakGlassClient
26
+ from workhold_admin.models import (
27
+ AdminMutationResult,
28
+ AuditOperation,
29
+ AuditPage,
30
+ AuditRecord,
31
+ Attempt,
32
+ AttemptOutcome,
33
+ AttemptPage,
34
+ BackoffStrategy,
35
+ BULK_BATCH_MAX,
36
+ BULK_CANDIDATE_MAX,
37
+ BreakGlassCounterResult,
38
+ BreakGlassMutationResult,
39
+ BreakGlassReplayLimitResult,
40
+ BulkExecuteResult,
41
+ BulkItemOutcome,
42
+ BulkItemOutcomeKind,
43
+ BulkOperation,
44
+ BulkPreviewResult,
45
+ CONFIRMATION_TOKEN_MAX_LENGTH,
46
+ ConfigVersion,
47
+ CURSOR_MAX_LENGTH,
48
+ DeadLetterPage,
49
+ DeadLetterReplayResult,
50
+ IDEMPOTENCY_KEY_MAX_LENGTH,
51
+ IDEMPOTENCY_KEY_MIN_LENGTH,
52
+ MaintenanceOutcome,
53
+ MaintenanceRunResult,
54
+ MaintenanceStatus,
55
+ PAGE_LIMIT_DEFAULT,
56
+ PAGE_LIMIT_MAX,
57
+ PAGE_LIMIT_MIN,
58
+ PolicyVersion,
59
+ Queue,
60
+ QueuePage,
61
+ QueueState,
62
+ REASON_MAX_LENGTH,
63
+ RetryPolicy,
64
+ RetryPolicyDraft,
65
+ StatsAvailability,
66
+ StatsFreshness,
67
+ StatsMaintenanceSummary,
68
+ StatsQueueAggregate,
69
+ StatsSnapshot,
70
+ StatsTelemetrySource,
71
+ StatsTelemetrySummary,
72
+ TaskPage,
73
+ )
74
+ from workhold_admin.observer import ObserverClient
75
+
76
+ __version__ = "1.0.0"
77
+
78
+ __all__ = [
79
+ "AdminClient",
80
+ "AdminMutationResult",
81
+ "AuditOperation",
82
+ "AuditPage",
83
+ "AuditRecord",
84
+ "Attempt",
85
+ "AttemptOutcome",
86
+ "AttemptPage",
87
+ "AuthenticationError",
88
+ "BackoffStrategy",
89
+ "BULK_BATCH_MAX",
90
+ "BULK_CANDIDATE_MAX",
91
+ "BreakGlassClient",
92
+ "BreakGlassCounterResult",
93
+ "BreakGlassMutationResult",
94
+ "BreakGlassReplayLimitResult",
95
+ "BulkExecuteResult",
96
+ "BulkItemOutcome",
97
+ "BulkItemOutcomeKind",
98
+ "BulkOperation",
99
+ "BulkPreviewResult",
100
+ "Capabilities",
101
+ "CONFIRMATION_TOKEN_MAX_LENGTH",
102
+ "ConfigVersion",
103
+ "CURSOR_MAX_LENGTH",
104
+ "DeadLetterPage",
105
+ "DeadLetterReplayResult",
106
+ "HttpJsonTransport",
107
+ "IDEMPOTENCY_KEY_MAX_LENGTH",
108
+ "IDEMPOTENCY_KEY_MIN_LENGTH",
109
+ "MaintenanceOutcome",
110
+ "MaintenanceRunResult",
111
+ "MaintenanceStatus",
112
+ "MalformedResponseError",
113
+ "ObserverClient",
114
+ "PAGE_LIMIT_DEFAULT",
115
+ "PAGE_LIMIT_MAX",
116
+ "PAGE_LIMIT_MIN",
117
+ "PolicyVersion",
118
+ "ProtocolError",
119
+ "Queue",
120
+ "QueueClientError",
121
+ "QueuePage",
122
+ "QueueState",
123
+ "REASON_MAX_LENGTH",
124
+ "RetryPolicy",
125
+ "RetryPolicyDraft",
126
+ "StatsAvailability",
127
+ "StatsFreshness",
128
+ "StatsMaintenanceSummary",
129
+ "StatsQueueAggregate",
130
+ "StatsSnapshot",
131
+ "StatsTelemetrySource",
132
+ "StatsTelemetrySummary",
133
+ "Task",
134
+ "TaskPage",
135
+ "TaskState",
136
+ "TimeoutError",
137
+ "TransportError",
138
+ "encode_path_segment",
139
+ "__version__",
140
+ ]
@@ -0,0 +1,573 @@
1
+ """Admin SDK adapter: queue/policy/state, audit/maintenance, and recovery."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from datetime import datetime
7
+ from typing import Any
8
+ from urllib.parse import urlencode
9
+
10
+ from _workhold_client_core.capabilities import Capabilities
11
+ from _workhold_client_core.errors import MalformedResponseError
12
+ from _workhold_client_core.transport import HttpJsonTransport, encode_path_segment
13
+
14
+ from workhold_admin.models import (
15
+ AdminMutationResult,
16
+ AttemptPage,
17
+ AuditPage,
18
+ BulkExecuteResult,
19
+ BulkPreviewResult,
20
+ ConfigVersion,
21
+ DeadLetterPage,
22
+ DeadLetterReplayResult,
23
+ MaintenanceRunResult,
24
+ MaintenanceStatus,
25
+ PAGE_LIMIT_DEFAULT,
26
+ PolicyVersion,
27
+ Queue,
28
+ QueuePage,
29
+ QueueState,
30
+ RetryPolicyDraft,
31
+ StatsSnapshot,
32
+ TaskPage,
33
+ format_datetime,
34
+ validate_batch_limit,
35
+ validate_bulk_filters,
36
+ validate_confirmation_token,
37
+ validate_cursor,
38
+ validate_idempotency_key,
39
+ validate_known_queue_state,
40
+ validate_page_limit,
41
+ validate_queue_name,
42
+ validate_reason,
43
+ validate_start_index,
44
+ validate_task_id,
45
+ validate_time_range,
46
+ )
47
+ from workhold_admin.observer import ObserverClient, _page_query
48
+
49
+
50
+ class AdminClient:
51
+ """Admin mutations: queue/policy/state, audit/maintenance, and recovery.
52
+
53
+ Single-transport form ``AdminClient(transport, bearer_token=...)`` uses the
54
+ same transport for admin-plane calls. Dual-transport form
55
+ ``AdminClient(public, bearer_token=..., admin_transport=admin)`` routes
56
+ admin-plane ops through ``admin_transport`` and composes ``ObserverClient``
57
+ for maintenance status reads. Break-glass operations are intentionally
58
+ absent. The SDK does not silently retry or auto-read stale optimistic
59
+ mutations.
60
+
61
+ Recovery: preview and execute are separate calls. Execute methods require an
62
+ explicit ``BulkPreviewResult`` from a prior preview; the SDK never
63
+ auto-previews or silently widens filters. Replay is at-least-once and may
64
+ repeat external effects; models preserve immutable ``source_task_id`` lineage.
65
+ """
66
+
67
+ def __init__(
68
+ self,
69
+ transport: HttpJsonTransport,
70
+ *,
71
+ bearer_token: str,
72
+ admin_transport: HttpJsonTransport | None = None,
73
+ ) -> None:
74
+ if not bearer_token or not bearer_token.strip():
75
+ raise ValueError("bearer_token is required")
76
+ resolved_admin = transport if admin_transport is None else admin_transport
77
+ self._observer = ObserverClient(
78
+ transport,
79
+ bearer_token=bearer_token,
80
+ admin_transport=resolved_admin,
81
+ )
82
+ self._transport = transport
83
+ self._admin_transport = resolved_admin
84
+ self._bearer_token = bearer_token
85
+
86
+ def __repr__(self) -> str:
87
+ return "AdminClient(transport=..., admin_transport=..., bearer_token=<redacted>)"
88
+
89
+ def _auth_headers(self, extra: dict[str, str] | None = None) -> dict[str, str]:
90
+ headers = {"Authorization": f"Bearer {self._bearer_token}"}
91
+ if extra:
92
+ headers.update(extra)
93
+ return headers
94
+
95
+ def list_queues(
96
+ self,
97
+ *,
98
+ cursor: str | None = None,
99
+ limit: int = PAGE_LIMIT_DEFAULT,
100
+ ) -> QueuePage:
101
+ """GET ``/admin/v1/queues`` (OpenAPI ``listQueues``)."""
102
+
103
+ wire_limit = validate_page_limit(limit)
104
+ wire_cursor = validate_cursor(cursor)
105
+ query = _page_query(limit=wire_limit, cursor=wire_cursor)
106
+ response = self._admin_transport.request(
107
+ "GET",
108
+ f"/admin/v1/queues{query}",
109
+ headers=self._auth_headers(),
110
+ json_body=None,
111
+ )
112
+ return self._parse(QueuePage.parse, response.status_code, response.body)
113
+
114
+ def create_queue(
115
+ self,
116
+ name: str,
117
+ *,
118
+ initial_policy: RetryPolicyDraft,
119
+ idempotency_key: str,
120
+ ) -> AdminMutationResult:
121
+ """POST ``/admin/v1/queues`` (OpenAPI ``createQueue``)."""
122
+
123
+ wire_name = validate_queue_name(name)
124
+ wire_key = validate_idempotency_key(idempotency_key)
125
+ body = {
126
+ "name": wire_name,
127
+ "initial_policy": initial_policy.to_wire(),
128
+ }
129
+ response = self._admin_transport.request(
130
+ "POST",
131
+ "/admin/v1/queues",
132
+ headers=self._auth_headers({"Idempotency-Key": wire_key}),
133
+ json_body=body,
134
+ )
135
+ return self._parse(AdminMutationResult.parse, response.status_code, response.body)
136
+
137
+ def get_queue(self, queue_name: str) -> Queue:
138
+ """GET ``/admin/v1/queues/{queue_name}`` (OpenAPI ``getQueue``)."""
139
+
140
+ wire_name = validate_queue_name(queue_name)
141
+ path = f"/admin/v1/queues/{encode_path_segment(wire_name)}"
142
+ response = self._admin_transport.request(
143
+ "GET",
144
+ path,
145
+ headers=self._auth_headers(),
146
+ json_body=None,
147
+ )
148
+ return self._parse(Queue.parse, response.status_code, response.body)
149
+
150
+ def create_queue_policy(
151
+ self,
152
+ queue_name: str,
153
+ policy: RetryPolicyDraft,
154
+ *,
155
+ idempotency_key: str,
156
+ ) -> AdminMutationResult:
157
+ """POST ``/admin/v1/queues/{queue_name}/policies`` (OpenAPI ``createQueuePolicy``)."""
158
+
159
+ wire_name = validate_queue_name(queue_name)
160
+ wire_key = validate_idempotency_key(idempotency_key)
161
+ path = f"/admin/v1/queues/{encode_path_segment(wire_name)}/policies"
162
+ response = self._admin_transport.request(
163
+ "POST",
164
+ path,
165
+ headers=self._auth_headers({"Idempotency-Key": wire_key}),
166
+ json_body=policy.to_wire(),
167
+ )
168
+ return self._parse(AdminMutationResult.parse, response.status_code, response.body)
169
+
170
+ def activate_queue_policy(
171
+ self,
172
+ queue_name: str,
173
+ policy_version: PolicyVersion | int,
174
+ *,
175
+ expected_config_version: ConfigVersion | int,
176
+ idempotency_key: str,
177
+ ) -> AdminMutationResult:
178
+ """POST ``/admin/v1/queues/{queue_name}/policies/{policy_version}:activate``."""
179
+
180
+ wire_name = validate_queue_name(queue_name)
181
+ wire_key = validate_idempotency_key(idempotency_key)
182
+ wire_policy_version = _wire_policy_version(policy_version)
183
+ wire_config_version = _wire_config_version(expected_config_version)
184
+ path = (
185
+ f"/admin/v1/queues/{encode_path_segment(wire_name)}/policies/"
186
+ f"{wire_policy_version}:activate"
187
+ )
188
+ body = {"expected_config_version": wire_config_version}
189
+ response = self._admin_transport.request(
190
+ "POST",
191
+ path,
192
+ headers=self._auth_headers({"Idempotency-Key": wire_key}),
193
+ json_body=body,
194
+ )
195
+ return self._parse(AdminMutationResult.parse, response.status_code, response.body)
196
+
197
+ def set_queue_state(
198
+ self,
199
+ queue_name: str,
200
+ state: QueueState | str,
201
+ *,
202
+ expected_config_version: ConfigVersion | int,
203
+ idempotency_key: str,
204
+ ) -> AdminMutationResult:
205
+ """POST ``/admin/v1/queues/{queue_name}:set-state`` (OpenAPI ``setQueueState``)."""
206
+
207
+ wire_name = validate_queue_name(queue_name)
208
+ wire_key = validate_idempotency_key(idempotency_key)
209
+ wire_config_version = _wire_config_version(expected_config_version)
210
+ wire_state = _wire_queue_state(state)
211
+ path = f"/admin/v1/queues/{encode_path_segment(wire_name)}:set-state"
212
+ body = {
213
+ "expected_config_version": wire_config_version,
214
+ "state": wire_state,
215
+ }
216
+ response = self._admin_transport.request(
217
+ "POST",
218
+ path,
219
+ headers=self._auth_headers({"Idempotency-Key": wire_key}),
220
+ json_body=body,
221
+ )
222
+ return self._parse(AdminMutationResult.parse, response.status_code, response.body)
223
+
224
+ def get_maintenance_status(self) -> MaintenanceStatus:
225
+ """GET ``/admin/v1/maintenance`` (OpenAPI ``getMaintenanceStatus``)."""
226
+
227
+ return self._observer.get_maintenance_status()
228
+
229
+ def get_capabilities(self) -> Capabilities:
230
+ """GET ``/v1/capabilities`` (OpenAPI ``getCapabilities``; admin-authorized)."""
231
+
232
+ return self._observer.get_capabilities()
233
+
234
+ def get_stats(self) -> StatsSnapshot:
235
+ """GET ``/admin/v1/stats`` (OpenAPI ``getStats``; parameterless)."""
236
+
237
+ return self._observer.get_stats()
238
+
239
+ def list_inspection_tasks(
240
+ self,
241
+ queue_name: str,
242
+ *,
243
+ cursor: str | None = None,
244
+ limit: int = PAGE_LIMIT_DEFAULT,
245
+ ) -> TaskPage:
246
+ """GET ``/admin/v1/tasks`` (OpenAPI ``listInspectionTasks``)."""
247
+
248
+ return self._observer.list_inspection_tasks(
249
+ queue_name, cursor=cursor, limit=limit
250
+ )
251
+
252
+ def list_inspection_attempts(
253
+ self,
254
+ task_id: str,
255
+ *,
256
+ time_from: datetime,
257
+ time_to: datetime,
258
+ cursor: str | None = None,
259
+ limit: int = PAGE_LIMIT_DEFAULT,
260
+ ) -> AttemptPage:
261
+ """GET ``/admin/v1/attempts`` (OpenAPI ``listInspectionAttempts``)."""
262
+
263
+ return self._observer.list_inspection_attempts(
264
+ task_id,
265
+ time_from=time_from,
266
+ time_to=time_to,
267
+ cursor=cursor,
268
+ limit=limit,
269
+ )
270
+
271
+ def list_dead_letters(
272
+ self,
273
+ queue_name: str,
274
+ *,
275
+ time_from: datetime,
276
+ time_to: datetime,
277
+ cursor: str | None = None,
278
+ limit: int = PAGE_LIMIT_DEFAULT,
279
+ ) -> DeadLetterPage:
280
+ """GET ``/admin/v1/dead-letters`` (OpenAPI ``listDeadLetters``)."""
281
+
282
+ return self._observer.list_dead_letters(
283
+ queue_name,
284
+ time_from=time_from,
285
+ time_to=time_to,
286
+ cursor=cursor,
287
+ limit=limit,
288
+ )
289
+
290
+ def list_admin_audit(
291
+ self,
292
+ *,
293
+ time_from: datetime,
294
+ time_to: datetime,
295
+ queue_name: str | None = None,
296
+ cursor: str | None = None,
297
+ limit: int = PAGE_LIMIT_DEFAULT,
298
+ ) -> AuditPage:
299
+ """GET ``/admin/v1/audit`` (OpenAPI ``listAdminAudit``)."""
300
+
301
+ validate_time_range(time_from, time_to)
302
+ wire_limit = validate_page_limit(limit)
303
+ wire_cursor = validate_cursor(cursor)
304
+ params: dict[str, str] = {
305
+ "from": format_datetime(time_from),
306
+ "to": format_datetime(time_to),
307
+ }
308
+ if queue_name is not None:
309
+ params["queue_name"] = validate_queue_name(queue_name)
310
+ params.update(_page_params(limit=wire_limit, cursor=wire_cursor))
311
+ path = f"/admin/v1/audit?{urlencode(params)}"
312
+ response = self._admin_transport.request(
313
+ "GET",
314
+ path,
315
+ headers=self._auth_headers(),
316
+ json_body=None,
317
+ )
318
+ return self._parse(AuditPage.parse, response.status_code, response.body)
319
+
320
+ def run_maintenance(self, *, idempotency_key: str) -> MaintenanceRunResult:
321
+ """POST ``/admin/v1/maintenance:run`` (OpenAPI ``runMaintenance``)."""
322
+
323
+ wire_key = validate_idempotency_key(idempotency_key)
324
+ response = self._admin_transport.request(
325
+ "POST",
326
+ "/admin/v1/maintenance:run",
327
+ headers=self._auth_headers({"Idempotency-Key": wire_key}),
328
+ json_body=None,
329
+ )
330
+ return self._parse(MaintenanceRunResult.parse, response.status_code, response.body)
331
+
332
+ def replay_dead_letter(
333
+ self,
334
+ queue_name: str,
335
+ task_id: str,
336
+ *,
337
+ idempotency_key: str,
338
+ reason: str,
339
+ ) -> DeadLetterReplayResult:
340
+ """POST ``.../dead-letters/{task_id}:replay`` (OpenAPI ``replayDeadLetter``).
341
+
342
+ Creates a new ready task linked to the immutable dead-letter source.
343
+ Never mutates terminal history. Replay is at-least-once; ``replayed`` may
344
+ be true when the same admin idempotency key is retried within ADR017 TTL.
345
+ """
346
+
347
+ wire_queue = validate_queue_name(queue_name)
348
+ wire_task_id = validate_task_id(task_id)
349
+ wire_key = validate_idempotency_key(idempotency_key)
350
+ wire_reason = validate_reason(reason)
351
+ path = (
352
+ f"/admin/v1/queues/{encode_path_segment(wire_queue)}"
353
+ f"/dead-letters/{encode_path_segment(wire_task_id)}:replay"
354
+ )
355
+ response = self._admin_transport.request(
356
+ "POST",
357
+ path,
358
+ headers=self._auth_headers({"Idempotency-Key": wire_key}),
359
+ json_body={"reason": wire_reason},
360
+ )
361
+ return self._parse(DeadLetterReplayResult.parse, response.status_code, response.body)
362
+
363
+ def preview_bulk_replay(
364
+ self,
365
+ queue_name: str,
366
+ *,
367
+ filters: Mapping[str, str] | None = None,
368
+ ) -> BulkPreviewResult:
369
+ """POST ``.../bulk:preview-replay`` (OpenAPI ``previewBulkReplay``).
370
+
371
+ Dry-run only: returns a principal-bound expiring confirmation token and
372
+ bounded candidate summary without mutating tasks.
373
+ """
374
+
375
+ return self._preview_bulk(
376
+ queue_name,
377
+ operation_path="bulk:preview-replay",
378
+ filters=filters,
379
+ )
380
+
381
+ def execute_bulk_replay(
382
+ self,
383
+ queue_name: str,
384
+ *,
385
+ preview: BulkPreviewResult,
386
+ idempotency_key: str,
387
+ reason: str,
388
+ filters: Mapping[str, str],
389
+ start_index: int = 0,
390
+ batch_limit: int | None = None,
391
+ ) -> BulkExecuteResult:
392
+ """POST ``.../bulk:execute-replay`` (OpenAPI ``executeBulkReplay``).
393
+
394
+ Requires ``preview`` from ``preview_bulk_replay`` with matching queue,
395
+ operation and caller-supplied ``filters``. Partial batches are explicit
396
+ via ``partial`` and ``next_start_index``; replay remains at-least-once.
397
+ """
398
+
399
+ return self._execute_bulk(
400
+ queue_name,
401
+ operation_path="bulk:execute-replay",
402
+ expected_operation="bulk_replay",
403
+ preview=preview,
404
+ idempotency_key=idempotency_key,
405
+ reason=reason,
406
+ filters=filters,
407
+ start_index=start_index,
408
+ batch_limit=batch_limit,
409
+ )
410
+
411
+ def preview_bulk_cancel(
412
+ self,
413
+ queue_name: str,
414
+ *,
415
+ filters: Mapping[str, str] | None = None,
416
+ ) -> BulkPreviewResult:
417
+ """POST ``.../bulk:preview-cancel`` (OpenAPI ``previewBulkCancel``).
418
+
419
+ Dry-run only: returns a confirmation token for bounded bulk cancellation.
420
+ """
421
+
422
+ return self._preview_bulk(
423
+ queue_name,
424
+ operation_path="bulk:preview-cancel",
425
+ filters=filters,
426
+ )
427
+
428
+ def execute_bulk_cancel(
429
+ self,
430
+ queue_name: str,
431
+ *,
432
+ preview: BulkPreviewResult,
433
+ reason: str,
434
+ filters: Mapping[str, str],
435
+ start_index: int = 0,
436
+ batch_limit: int | None = None,
437
+ ) -> BulkExecuteResult:
438
+ """POST ``.../bulk:execute-cancel`` (OpenAPI ``executeBulkCancel``).
439
+
440
+ Requires ``preview`` from ``preview_bulk_cancel`` with matching queue,
441
+ operation and ``filters``. Never creates spawn or delivery events.
442
+ """
443
+
444
+ return self._execute_bulk(
445
+ queue_name,
446
+ operation_path="bulk:execute-cancel",
447
+ expected_operation="bulk_cancel",
448
+ preview=preview,
449
+ idempotency_key=None,
450
+ reason=reason,
451
+ filters=filters,
452
+ start_index=start_index,
453
+ batch_limit=batch_limit,
454
+ )
455
+
456
+ def _preview_bulk(
457
+ self,
458
+ queue_name: str,
459
+ *,
460
+ operation_path: str,
461
+ filters: Mapping[str, str] | None,
462
+ ) -> BulkPreviewResult:
463
+ wire_queue = validate_queue_name(queue_name)
464
+ body: dict[str, Any] = {}
465
+ if filters is not None:
466
+ body["filters"] = validate_bulk_filters(filters)
467
+ path = f"/admin/v1/queues/{encode_path_segment(wire_queue)}/{operation_path}"
468
+ response = self._admin_transport.request(
469
+ "POST",
470
+ path,
471
+ headers=self._auth_headers(),
472
+ json_body=body,
473
+ )
474
+ result = self._parse(BulkPreviewResult.parse, response.status_code, response.body)
475
+ if result.queue != wire_queue:
476
+ raise ValueError("preview.queue does not match queue_name")
477
+ return result
478
+
479
+ def _execute_bulk(
480
+ self,
481
+ queue_name: str,
482
+ *,
483
+ operation_path: str,
484
+ expected_operation: str,
485
+ preview: BulkPreviewResult,
486
+ idempotency_key: str | None,
487
+ reason: str,
488
+ filters: Mapping[str, str],
489
+ start_index: int,
490
+ batch_limit: int | None,
491
+ ) -> BulkExecuteResult:
492
+ wire_queue = validate_queue_name(queue_name)
493
+ self._assert_execute_preview_binding(preview, wire_queue, expected_operation)
494
+ wire_token = validate_confirmation_token(preview.confirmation_token)
495
+ wire_reason = validate_reason(reason)
496
+ wire_filters = validate_bulk_filters(filters)
497
+ wire_start = validate_start_index(start_index)
498
+ wire_batch_limit = validate_batch_limit(batch_limit)
499
+ body: dict[str, Any] = {
500
+ "confirmation_token": wire_token,
501
+ "filters": wire_filters,
502
+ "reason": wire_reason,
503
+ "start_index": wire_start,
504
+ }
505
+ if wire_batch_limit is not None:
506
+ body["batch_limit"] = wire_batch_limit
507
+ extra_headers: dict[str, str] = {}
508
+ if idempotency_key is not None:
509
+ extra_headers["Idempotency-Key"] = validate_idempotency_key(idempotency_key)
510
+ path = f"/admin/v1/queues/{encode_path_segment(wire_queue)}/{operation_path}"
511
+ response = self._admin_transport.request(
512
+ "POST",
513
+ path,
514
+ headers=self._auth_headers(extra_headers or None),
515
+ json_body=body,
516
+ )
517
+ return self._parse(BulkExecuteResult.parse, response.status_code, response.body)
518
+
519
+ @staticmethod
520
+ def _assert_execute_preview_binding(
521
+ preview: BulkPreviewResult,
522
+ queue_name: str,
523
+ expected_operation: str,
524
+ ) -> None:
525
+ if preview.queue != queue_name:
526
+ raise ValueError("preview.queue does not match queue_name")
527
+ if preview.operation.value != expected_operation:
528
+ raise ValueError(
529
+ f"preview.operation must be {expected_operation!r}, "
530
+ f"got {preview.operation.value!r}"
531
+ )
532
+
533
+ @staticmethod
534
+ def _parse(parser: Any, status_code: int, body: object | None) -> Any:
535
+ if body is None:
536
+ raise MalformedResponseError(
537
+ status_code=status_code,
538
+ reason="empty success response body",
539
+ )
540
+ try:
541
+ return parser(body)
542
+ except ValueError as exc:
543
+ raise MalformedResponseError(
544
+ status_code=status_code,
545
+ reason=str(exc),
546
+ ) from exc
547
+
548
+
549
+ def _wire_config_version(value: ConfigVersion | int) -> int:
550
+ if isinstance(value, ConfigVersion):
551
+ return value.to_wire()
552
+ return ConfigVersion.parse(value).to_wire()
553
+
554
+
555
+ def _wire_policy_version(value: PolicyVersion | int) -> int:
556
+ if isinstance(value, PolicyVersion):
557
+ return value.to_wire()
558
+ return PolicyVersion.parse(value).to_wire()
559
+
560
+
561
+ def _wire_queue_state(state: QueueState | str) -> str:
562
+ if isinstance(state, QueueState):
563
+ return validate_known_queue_state(state).value
564
+ if not isinstance(state, str) or not state:
565
+ raise ValueError("state must be a non-empty string")
566
+ return validate_known_queue_state(QueueState.parse(state)).value
567
+
568
+
569
+ def _page_params(*, limit: int, cursor: str | None) -> dict[str, str]:
570
+ params: dict[str, str] = {"limit": str(limit)}
571
+ if cursor is not None:
572
+ params["cursor"] = cursor
573
+ return params