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,478 @@
1
+ """Bounded lazy sync pagination for Observer/Admin cursor-list operations.
2
+
3
+ Requires an explicit positive ``max_pages`` and/or ``max_items`` ceiling.
4
+ Preserves caller filters and page size on every request, forwards server
5
+ cursors only (never synthesizes), and surfaces the last server cursor plus
6
+ page/item counts. Does not prefetch or retry cursor protocol errors.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from collections.abc import Callable, Iterator
12
+ from dataclasses import dataclass, field
13
+ from datetime import datetime
14
+ from typing import Generic, Protocol, TypeVar
15
+
16
+ from workhold_admin.admin import AdminClient
17
+ from workhold_admin.models import (
18
+ PAGE_LIMIT_DEFAULT,
19
+ AttemptPage,
20
+ AuditPage,
21
+ DeadLetterPage,
22
+ QueuePage,
23
+ TaskPage,
24
+ )
25
+ from workhold_admin.observer import ObserverClient
26
+
27
+ P = TypeVar("P", bound="CursorPage")
28
+ T = TypeVar("T")
29
+
30
+
31
+ class CursorPage(Protocol):
32
+ """Minimal page shape required by bounded iterators."""
33
+
34
+ @property
35
+ def items(self) -> tuple[object, ...]: ...
36
+
37
+ @property
38
+ def next_cursor(self) -> str | None: ...
39
+
40
+
41
+ def _effective_max_pages(max_pages: int | None, max_items: int | None) -> int | None:
42
+ """When only ``max_items`` is set, bound page fetches to avoid empty-cursor spin."""
43
+
44
+ if max_pages is not None:
45
+ return max_pages
46
+ if max_items is not None:
47
+ return max_items
48
+ return None
49
+
50
+
51
+ def _validate_bounds(*, max_pages: int | None, max_items: int | None) -> None:
52
+ if max_pages is None and max_items is None:
53
+ raise ValueError("at least one of max_pages or max_items is required")
54
+ if max_pages is not None and (
55
+ not isinstance(max_pages, int) or isinstance(max_pages, bool) or max_pages < 1
56
+ ):
57
+ raise ValueError("max_pages must be a positive integer")
58
+ if max_items is not None and (
59
+ not isinstance(max_items, int) or isinstance(max_items, bool) or max_items < 1
60
+ ):
61
+ raise ValueError("max_items must be a positive integer")
62
+
63
+
64
+ @dataclass(slots=True)
65
+ class BoundedPageIterator(Generic[P]):
66
+ """Lazy page iterator that stops at ``max_pages`` and/or ``max_items``."""
67
+
68
+ _fetch: Callable[[str | None], P]
69
+ _max_pages: int | None
70
+ _max_items: int | None
71
+ _cursor: str | None = None
72
+ _pages_fetched: int = 0
73
+ _items_seen: int = 0
74
+ _last_cursor: str | None = None
75
+ _stopped: bool = False
76
+
77
+ @property
78
+ def last_cursor(self) -> str | None:
79
+ """Most recent ``next_cursor`` returned by the server (may be ``None``)."""
80
+
81
+ return self._last_cursor
82
+
83
+ @property
84
+ def page_count(self) -> int:
85
+ return self._pages_fetched
86
+
87
+ @property
88
+ def item_count(self) -> int:
89
+ return self._items_seen
90
+
91
+ def __iter__(self) -> Iterator[P]:
92
+ return self
93
+
94
+ def __next__(self) -> P:
95
+ if self._stopped:
96
+ raise StopIteration
97
+ if self._max_pages is not None and self._pages_fetched >= self._max_pages:
98
+ self._stopped = True
99
+ raise StopIteration
100
+ if self._max_items is not None and self._items_seen >= self._max_items:
101
+ self._stopped = True
102
+ raise StopIteration
103
+
104
+ page = self._fetch(self._cursor)
105
+ self._pages_fetched += 1
106
+ self._last_cursor = page.next_cursor
107
+ self._items_seen += len(page.items)
108
+
109
+ if page.next_cursor is None:
110
+ self._stopped = True
111
+ else:
112
+ self._cursor = page.next_cursor
113
+ return page
114
+
115
+
116
+ @dataclass(slots=True)
117
+ class BoundedItemIterator(Generic[T]):
118
+ """Lazy item iterator over cursor pages with the same ceilings."""
119
+
120
+ _fetch: Callable[[str | None], CursorPage]
121
+ _max_pages: int | None
122
+ _max_items: int | None
123
+ _cursor: str | None = None
124
+ _pages_fetched: int = 0
125
+ _items_yielded: int = 0
126
+ _last_cursor: str | None = None
127
+ _pending: list[T] = field(default_factory=list)
128
+ _pages_exhausted: bool = False
129
+ _stopped: bool = False
130
+
131
+ @property
132
+ def last_cursor(self) -> str | None:
133
+ return self._last_cursor
134
+
135
+ @property
136
+ def page_count(self) -> int:
137
+ return self._pages_fetched
138
+
139
+ @property
140
+ def item_count(self) -> int:
141
+ return self._items_yielded
142
+
143
+ def __iter__(self) -> Iterator[T]:
144
+ return self
145
+
146
+ def __next__(self) -> T:
147
+ while True:
148
+ if self._pending:
149
+ item = self._pending.pop(0)
150
+ self._items_yielded += 1
151
+ if self._max_items is not None and self._items_yielded >= self._max_items:
152
+ self._pending.clear()
153
+ self._stopped = True
154
+ return item
155
+
156
+ if self._stopped or self._pages_exhausted:
157
+ self._stopped = True
158
+ raise StopIteration
159
+ if self._max_pages is not None and self._pages_fetched >= self._max_pages:
160
+ self._stopped = True
161
+ raise StopIteration
162
+ if self._max_items is not None and self._items_yielded >= self._max_items:
163
+ self._stopped = True
164
+ raise StopIteration
165
+
166
+ page = self._fetch(self._cursor)
167
+ self._pages_fetched += 1
168
+ self._last_cursor = page.next_cursor
169
+ self._pending = list(page.items) # type: ignore[arg-type]
170
+ if page.next_cursor is None:
171
+ self._pages_exhausted = True
172
+ else:
173
+ self._cursor = page.next_cursor
174
+ if not self._pending:
175
+ continue
176
+
177
+
178
+ def bounded_page_iterator(
179
+ fetch: Callable[[str | None], P],
180
+ *,
181
+ max_pages: int | None = None,
182
+ max_items: int | None = None,
183
+ cursor: str | None = None,
184
+ ) -> BoundedPageIterator[P]:
185
+ _validate_bounds(max_pages=max_pages, max_items=max_items)
186
+ return BoundedPageIterator(
187
+ _fetch=fetch,
188
+ _max_pages=_effective_max_pages(max_pages, max_items),
189
+ _max_items=max_items,
190
+ _cursor=cursor,
191
+ )
192
+
193
+
194
+ def bounded_item_iterator(
195
+ fetch: Callable[[str | None], CursorPage],
196
+ *,
197
+ max_pages: int | None = None,
198
+ max_items: int | None = None,
199
+ cursor: str | None = None,
200
+ ) -> BoundedItemIterator[T]:
201
+ _validate_bounds(max_pages=max_pages, max_items=max_items)
202
+ return BoundedItemIterator(
203
+ _fetch=fetch,
204
+ _max_pages=_effective_max_pages(max_pages, max_items),
205
+ _max_items=max_items,
206
+ _cursor=cursor,
207
+ )
208
+
209
+
210
+ def iter_task_attempt_pages(
211
+ client: ObserverClient,
212
+ task_id: str,
213
+ *,
214
+ max_pages: int | None = None,
215
+ max_items: int | None = None,
216
+ limit: int = PAGE_LIMIT_DEFAULT,
217
+ cursor: str | None = None,
218
+ ) -> BoundedPageIterator[AttemptPage]:
219
+ return bounded_page_iterator(
220
+ lambda cur: client.list_task_attempts(task_id, cursor=cur, limit=limit),
221
+ max_pages=max_pages,
222
+ max_items=max_items,
223
+ cursor=cursor,
224
+ )
225
+
226
+
227
+ def iter_task_attempts(
228
+ client: ObserverClient,
229
+ task_id: str,
230
+ *,
231
+ max_pages: int | None = None,
232
+ max_items: int | None = None,
233
+ limit: int = PAGE_LIMIT_DEFAULT,
234
+ cursor: str | None = None,
235
+ ) -> BoundedItemIterator[object]:
236
+ return bounded_item_iterator(
237
+ lambda cur: client.list_task_attempts(task_id, cursor=cur, limit=limit),
238
+ max_pages=max_pages,
239
+ max_items=max_items,
240
+ cursor=cursor,
241
+ )
242
+
243
+
244
+ def iter_inspection_task_pages(
245
+ client: ObserverClient,
246
+ queue_name: str,
247
+ *,
248
+ max_pages: int | None = None,
249
+ max_items: int | None = None,
250
+ limit: int = PAGE_LIMIT_DEFAULT,
251
+ cursor: str | None = None,
252
+ ) -> BoundedPageIterator[TaskPage]:
253
+ return bounded_page_iterator(
254
+ lambda cur: client.list_inspection_tasks(queue_name, cursor=cur, limit=limit),
255
+ max_pages=max_pages,
256
+ max_items=max_items,
257
+ cursor=cursor,
258
+ )
259
+
260
+
261
+ def iter_inspection_tasks(
262
+ client: ObserverClient,
263
+ queue_name: str,
264
+ *,
265
+ max_pages: int | None = None,
266
+ max_items: int | None = None,
267
+ limit: int = PAGE_LIMIT_DEFAULT,
268
+ cursor: str | None = None,
269
+ ) -> BoundedItemIterator[object]:
270
+ return bounded_item_iterator(
271
+ lambda cur: client.list_inspection_tasks(queue_name, cursor=cur, limit=limit),
272
+ max_pages=max_pages,
273
+ max_items=max_items,
274
+ cursor=cursor,
275
+ )
276
+
277
+
278
+ def iter_inspection_attempt_pages(
279
+ client: ObserverClient,
280
+ task_id: str,
281
+ *,
282
+ time_from: datetime,
283
+ time_to: datetime,
284
+ max_pages: int | None = None,
285
+ max_items: int | None = None,
286
+ limit: int = PAGE_LIMIT_DEFAULT,
287
+ cursor: str | None = None,
288
+ ) -> BoundedPageIterator[AttemptPage]:
289
+ return bounded_page_iterator(
290
+ lambda cur: client.list_inspection_attempts(
291
+ task_id,
292
+ time_from=time_from,
293
+ time_to=time_to,
294
+ cursor=cur,
295
+ limit=limit,
296
+ ),
297
+ max_pages=max_pages,
298
+ max_items=max_items,
299
+ cursor=cursor,
300
+ )
301
+
302
+
303
+ def iter_inspection_attempts(
304
+ client: ObserverClient,
305
+ task_id: str,
306
+ *,
307
+ time_from: datetime,
308
+ time_to: datetime,
309
+ max_pages: int | None = None,
310
+ max_items: int | None = None,
311
+ limit: int = PAGE_LIMIT_DEFAULT,
312
+ cursor: str | None = None,
313
+ ) -> BoundedItemIterator[object]:
314
+ return bounded_item_iterator(
315
+ lambda cur: client.list_inspection_attempts(
316
+ task_id,
317
+ time_from=time_from,
318
+ time_to=time_to,
319
+ cursor=cur,
320
+ limit=limit,
321
+ ),
322
+ max_pages=max_pages,
323
+ max_items=max_items,
324
+ cursor=cursor,
325
+ )
326
+
327
+
328
+ def iter_dead_letter_pages(
329
+ client: ObserverClient,
330
+ queue_name: str,
331
+ *,
332
+ time_from: datetime,
333
+ time_to: datetime,
334
+ max_pages: int | None = None,
335
+ max_items: int | None = None,
336
+ limit: int = PAGE_LIMIT_DEFAULT,
337
+ cursor: str | None = None,
338
+ ) -> BoundedPageIterator[DeadLetterPage]:
339
+ return bounded_page_iterator(
340
+ lambda cur: client.list_dead_letters(
341
+ queue_name,
342
+ time_from=time_from,
343
+ time_to=time_to,
344
+ cursor=cur,
345
+ limit=limit,
346
+ ),
347
+ max_pages=max_pages,
348
+ max_items=max_items,
349
+ cursor=cursor,
350
+ )
351
+
352
+
353
+ def iter_dead_letters(
354
+ client: ObserverClient,
355
+ queue_name: str,
356
+ *,
357
+ time_from: datetime,
358
+ time_to: datetime,
359
+ max_pages: int | None = None,
360
+ max_items: int | None = None,
361
+ limit: int = PAGE_LIMIT_DEFAULT,
362
+ cursor: str | None = None,
363
+ ) -> BoundedItemIterator[object]:
364
+ return bounded_item_iterator(
365
+ lambda cur: client.list_dead_letters(
366
+ queue_name,
367
+ time_from=time_from,
368
+ time_to=time_to,
369
+ cursor=cur,
370
+ limit=limit,
371
+ ),
372
+ max_pages=max_pages,
373
+ max_items=max_items,
374
+ cursor=cursor,
375
+ )
376
+
377
+
378
+ def iter_queue_pages(
379
+ client: AdminClient,
380
+ *,
381
+ max_pages: int | None = None,
382
+ max_items: int | None = None,
383
+ limit: int = PAGE_LIMIT_DEFAULT,
384
+ cursor: str | None = None,
385
+ ) -> BoundedPageIterator[QueuePage]:
386
+ return bounded_page_iterator(
387
+ lambda cur: client.list_queues(cursor=cur, limit=limit),
388
+ max_pages=max_pages,
389
+ max_items=max_items,
390
+ cursor=cursor,
391
+ )
392
+
393
+
394
+ def iter_queues(
395
+ client: AdminClient,
396
+ *,
397
+ max_pages: int | None = None,
398
+ max_items: int | None = None,
399
+ limit: int = PAGE_LIMIT_DEFAULT,
400
+ cursor: str | None = None,
401
+ ) -> BoundedItemIterator[object]:
402
+ return bounded_item_iterator(
403
+ lambda cur: client.list_queues(cursor=cur, limit=limit),
404
+ max_pages=max_pages,
405
+ max_items=max_items,
406
+ cursor=cursor,
407
+ )
408
+
409
+
410
+ def iter_admin_audit_pages(
411
+ client: AdminClient,
412
+ *,
413
+ time_from: datetime,
414
+ time_to: datetime,
415
+ queue_name: str | None = None,
416
+ max_pages: int | None = None,
417
+ max_items: int | None = None,
418
+ limit: int = PAGE_LIMIT_DEFAULT,
419
+ cursor: str | None = None,
420
+ ) -> BoundedPageIterator[AuditPage]:
421
+ return bounded_page_iterator(
422
+ lambda cur: client.list_admin_audit(
423
+ time_from=time_from,
424
+ time_to=time_to,
425
+ queue_name=queue_name,
426
+ cursor=cur,
427
+ limit=limit,
428
+ ),
429
+ max_pages=max_pages,
430
+ max_items=max_items,
431
+ cursor=cursor,
432
+ )
433
+
434
+
435
+ def iter_admin_audit(
436
+ client: AdminClient,
437
+ *,
438
+ time_from: datetime,
439
+ time_to: datetime,
440
+ queue_name: str | None = None,
441
+ max_pages: int | None = None,
442
+ max_items: int | None = None,
443
+ limit: int = PAGE_LIMIT_DEFAULT,
444
+ cursor: str | None = None,
445
+ ) -> BoundedItemIterator[object]:
446
+ return bounded_item_iterator(
447
+ lambda cur: client.list_admin_audit(
448
+ time_from=time_from,
449
+ time_to=time_to,
450
+ queue_name=queue_name,
451
+ cursor=cur,
452
+ limit=limit,
453
+ ),
454
+ max_pages=max_pages,
455
+ max_items=max_items,
456
+ cursor=cursor,
457
+ )
458
+
459
+
460
+ __all__ = [
461
+ "BoundedItemIterator",
462
+ "BoundedPageIterator",
463
+ "CursorPage",
464
+ "bounded_item_iterator",
465
+ "bounded_page_iterator",
466
+ "iter_admin_audit",
467
+ "iter_admin_audit_pages",
468
+ "iter_dead_letter_pages",
469
+ "iter_dead_letters",
470
+ "iter_inspection_attempt_pages",
471
+ "iter_inspection_attempts",
472
+ "iter_inspection_task_pages",
473
+ "iter_inspection_tasks",
474
+ "iter_queue_pages",
475
+ "iter_queues",
476
+ "iter_task_attempt_pages",
477
+ "iter_task_attempts",
478
+ ]
@@ -0,0 +1,10 @@
1
+ Metadata-Version: 2.3
2
+ Name: workhold-admin
3
+ Version: 1.0.0
4
+ Summary: Workhold admin/observer/break-glass client (public shell)
5
+ Author: Mikhail Konin
6
+ Author-email: Mikhail Konin <dev.konin@gmail.com>
7
+ Requires-Dist: workhold-client-core>=1.0.0,<1.1.0
8
+ Requires-Dist: workhold-client-core[async]>=1.0.0,<1.1.0 ; extra == 'async'
9
+ Requires-Python: >=3.13
10
+ Provides-Extra: async
@@ -0,0 +1,11 @@
1
+ workhold_admin/__init__.py,sha256=wc9_XjcWaWSiT6-4EzKDQqxgVpfcE17UJtXvD2XTxqQ,3621
2
+ workhold_admin/admin.py,sha256=q25WerW_rHmIHeC2ljJVJDMEx8xW1ySWNFgsORf2RFw,19885
3
+ workhold_admin/async_client.py,sha256=Q2jV50uM6Nrk28Ibf6wjNoS-JpJgVcUbSVgE0J2i9FY,36523
4
+ workhold_admin/async_pagination.py,sha256=8Ex8NlSaDnTSWEBupCC8tHuG7aojTR7sO9LWPo4MMJs,12734
5
+ workhold_admin/break_glass.py,sha256=C2V_nz8z52VbuB27QRFlph3VE__faz3GGR3y4woV2dc,10080
6
+ workhold_admin/models.py,sha256=-LLb9P-0y9ADQlM6FPEbEjXPOc82M1yor3a3l3_V8CM,55039
7
+ workhold_admin/observer.py,sha256=PHNaQJrxR-s5zINo6Gr0nxki24xchXLM_hEHJvFa61w,8602
8
+ workhold_admin/pagination.py,sha256=kNPi2A4DbygLFObcKHvEZXP-wBEcuAVlLRVfkE0EV5M,13279
9
+ workhold_admin-1.0.0.dist-info/WHEEL,sha256=xTCBFIZIIyRSxyowOv5Wqg_jFU6KsdIffuulBB09Ph8,80
10
+ workhold_admin-1.0.0.dist-info/METADATA,sha256=lvCw0P2-Bo00bR6mwrNXlmv5_UbJhfPRUi_R6KhWLSM,369
11
+ workhold_admin-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.13.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any