b24api 2.1.0__tar.gz → 2.2.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.
Files changed (99) hide show
  1. {b24api-2.1.0/b24api.egg-info → b24api-2.2.0}/PKG-INFO +58 -17
  2. {b24api-2.1.0 → b24api-2.2.0}/README.md +57 -16
  3. {b24api-2.1.0 → b24api-2.2.0}/b24api/__init__.py +32 -0
  4. {b24api-2.1.0 → b24api-2.2.0}/b24api/_client_traversal.py +14 -4
  5. {b24api-2.1.0 → b24api-2.2.0}/b24api/cli.py +35 -2
  6. {b24api-2.1.0 → b24api-2.2.0}/b24api/cli_contract.py +42 -4
  7. {b24api-2.1.0 → b24api-2.2.0}/b24api/client.py +68 -1
  8. {b24api-2.1.0 → b24api-2.2.0}/b24api/contracts/__init__.py +24 -1
  9. {b24api-2.1.0 → b24api-2.2.0}/b24api/contracts/dispatch.py +7 -0
  10. {b24api-2.1.0 → b24api-2.2.0}/b24api/contracts/json.py +1 -1
  11. b24api-2.2.0/b24api/contracts/keyset_capability.py +246 -0
  12. {b24api-2.1.0 → b24api-2.2.0}/b24api/contracts/keyset_execution.py +1 -0
  13. b24api-2.2.0/b24api/contracts/page.py +70 -0
  14. {b24api-2.1.0 → b24api-2.2.0}/b24api/contracts/reference.py +3 -0
  15. {b24api-2.1.0 → b24api-2.2.0}/b24api/contracts/report.py +8 -0
  16. {b24api-2.1.0 → b24api-2.2.0}/b24api/contracts/response.py +4 -0
  17. {b24api-2.1.0 → b24api-2.2.0}/b24api/contracts/traversal.py +6 -0
  18. {b24api-2.1.0 → b24api-2.2.0}/b24api/errors.py +109 -1
  19. {b24api-2.1.0 → b24api-2.2.0}/b24api/execution/context.py +13 -8
  20. {b24api-2.1.0 → b24api-2.2.0}/b24api/references/binding.py +20 -3
  21. b24api-2.2.0/b24api/references/dispatch.py +689 -0
  22. {b24api-2.1.0 → b24api-2.2.0}/b24api/references/facade.py +9 -9
  23. {b24api-2.1.0 → b24api-2.2.0}/b24api/references/outcome.py +27 -0
  24. {b24api-2.1.0 → b24api-2.2.0}/b24api/references/scheduler.py +99 -5
  25. {b24api-2.1.0 → b24api-2.2.0}/b24api/references/stream.py +5 -0
  26. {b24api-2.1.0 → b24api-2.2.0}/b24api/traversal/counted.py +5 -1
  27. {b24api-2.1.0 → b24api-2.2.0}/b24api/traversal/counted_batch.py +8 -8
  28. {b24api-2.1.0 → b24api-2.2.0}/b24api/traversal/cursor.py +15 -6
  29. {b24api-2.1.0 → b24api-2.2.0}/b24api/traversal/driver.py +72 -21
  30. {b24api-2.1.0 → b24api-2.2.0}/b24api/traversal/facade.py +13 -1
  31. {b24api-2.1.0 → b24api-2.2.0}/b24api/traversal/identity.py +3 -2
  32. {b24api-2.1.0 → b24api-2.2.0}/b24api/traversal/keyset.py +5 -4
  33. {b24api-2.1.0 → b24api-2.2.0}/b24api/traversal/keyset_auto.py +1 -6
  34. {b24api-2.1.0 → b24api-2.2.0}/b24api/traversal/keyset_capability.py +3 -3
  35. {b24api-2.1.0 → b24api-2.2.0}/b24api/traversal/keyset_fast_plan.py +4 -4
  36. {b24api-2.1.0 → b24api-2.2.0}/b24api/traversal/keyset_fast_stream.py +4 -4
  37. {b24api-2.1.0 → b24api-2.2.0}/b24api/traversal/keyset_observation.py +3 -23
  38. {b24api-2.1.0 → b24api-2.2.0}/b24api/traversal/keyset_reporting.py +2 -3
  39. {b24api-2.1.0 → b24api-2.2.0}/b24api/traversal/keyset_scheduler.py +9 -55
  40. {b24api-2.1.0 → b24api-2.2.0}/b24api/traversal/keyset_transaction_contract.py +4 -4
  41. {b24api-2.1.0 → b24api-2.2.0}/b24api/traversal/keyset_transactions.py +21 -29
  42. b24api-2.2.0/b24api/traversal/keyset_verifier.py +498 -0
  43. {b24api-2.1.0 → b24api-2.2.0}/b24api/traversal/ordered_admission.py +3 -3
  44. b24api-2.2.0/b24api/traversal/page_adaptation.py +90 -0
  45. {b24api-2.1.0 → b24api-2.2.0}/b24api/traversal/page_validation.py +26 -6
  46. {b24api-2.1.0 → b24api-2.2.0}/b24api/traversal/plans.py +7 -0
  47. {b24api-2.1.0 → b24api-2.2.0}/b24api/traversal/sequential.py +11 -10
  48. {b24api-2.1.0 → b24api-2.2.0}/b24api/traversal/stream.py +9 -2
  49. {b24api-2.1.0 → b24api-2.2.0}/b24api/traversal/values.py +54 -36
  50. {b24api-2.1.0 → b24api-2.2.0/b24api.egg-info}/PKG-INFO +58 -17
  51. {b24api-2.1.0 → b24api-2.2.0}/b24api.egg-info/SOURCES.txt +4 -0
  52. b24api-2.1.0/b24api/references/dispatch.py +0 -435
  53. {b24api-2.1.0 → b24api-2.2.0}/LICENSE +0 -0
  54. {b24api-2.1.0 → b24api-2.2.0}/MANIFEST.in +0 -0
  55. {b24api-2.1.0 → b24api-2.2.0}/b24api/_audit.py +0 -0
  56. {b24api-2.1.0 → b24api-2.2.0}/b24api/_error_types.py +0 -0
  57. {b24api-2.1.0 → b24api-2.2.0}/b24api/_stream.py +0 -0
  58. {b24api-2.1.0 → b24api-2.2.0}/b24api/batch/__init__.py +0 -0
  59. {b24api-2.1.0 → b24api-2.2.0}/b24api/batch/engine.py +0 -0
  60. {b24api-2.1.0 → b24api-2.2.0}/b24api/batch/facade.py +0 -0
  61. {b24api-2.1.0 → b24api-2.2.0}/b24api/batch/logical.py +0 -0
  62. {b24api-2.1.0 → b24api-2.2.0}/b24api/batch/outcome.py +0 -0
  63. {b24api-2.1.0 → b24api-2.2.0}/b24api/batch/stream.py +0 -0
  64. {b24api-2.1.0 → b24api-2.2.0}/b24api/contracts/command.py +0 -0
  65. {b24api-2.1.0 → b24api-2.2.0}/b24api/contracts/policy.py +0 -0
  66. {b24api-2.1.0 → b24api-2.2.0}/b24api/contracts/request.py +0 -0
  67. {b24api-2.1.0 → b24api-2.2.0}/b24api/contracts/stream.py +0 -0
  68. {b24api-2.1.0 → b24api-2.2.0}/b24api/contracts/wire.py +0 -0
  69. {b24api-2.1.0 → b24api-2.2.0}/b24api/encoding.py +0 -0
  70. {b24api-2.1.0 → b24api-2.2.0}/b24api/execution/__init__.py +0 -0
  71. {b24api-2.1.0 → b24api-2.2.0}/b24api/execution/cleanup.py +0 -0
  72. {b24api-2.1.0 → b24api-2.2.0}/b24api/execution/executor.py +0 -0
  73. {b24api-2.1.0 → b24api-2.2.0}/b24api/execution/failure.py +0 -0
  74. {b24api-2.1.0 → b24api-2.2.0}/b24api/execution/rate.py +0 -0
  75. {b24api-2.1.0 → b24api-2.2.0}/b24api/execution/snapshot.py +0 -0
  76. {b24api-2.1.0 → b24api-2.2.0}/b24api/redaction.py +0 -0
  77. {b24api-2.1.0 → b24api-2.2.0}/b24api/references/__init__.py +0 -0
  78. {b24api-2.1.0 → b24api-2.2.0}/b24api/references/fanout.py +0 -0
  79. {b24api-2.1.0 → b24api-2.2.0}/b24api/references/support.py +0 -0
  80. {b24api-2.1.0 → b24api-2.2.0}/b24api/settings.py +0 -0
  81. {b24api-2.1.0 → b24api-2.2.0}/b24api/testing/__init__.py +0 -0
  82. {b24api-2.1.0 → b24api-2.2.0}/b24api/testing/_isolation.py +0 -0
  83. {b24api-2.1.0 → b24api-2.2.0}/b24api/testing/transport.py +0 -0
  84. {b24api-2.1.0 → b24api-2.2.0}/b24api/transport/__init__.py +0 -0
  85. {b24api-2.1.0 → b24api-2.2.0}/b24api/transport/base.py +0 -0
  86. {b24api-2.1.0 → b24api-2.2.0}/b24api/transport/httpx.py +0 -0
  87. {b24api-2.1.0 → b24api-2.2.0}/b24api/transport/protocol.py +0 -0
  88. {b24api-2.1.0 → b24api-2.2.0}/b24api/traversal/__init__.py +0 -0
  89. {b24api-2.1.0 → b24api-2.2.0}/b24api/traversal/keyset_costs.py +0 -0
  90. {b24api-2.1.0 → b24api-2.2.0}/b24api/traversal/keyset_eligibility.py +0 -0
  91. {b24api-2.1.0 → b24api-2.2.0}/b24api/traversal/keyset_partition.py +0 -0
  92. {b24api-2.1.0 → b24api-2.2.0}/b24api/traversal/keyset_range.py +0 -0
  93. {b24api-2.1.0 → b24api-2.2.0}/b24api/traversal/keyset_step.py +0 -0
  94. {b24api-2.1.0 → b24api-2.2.0}/b24api.egg-info/dependency_links.txt +0 -0
  95. {b24api-2.1.0 → b24api-2.2.0}/b24api.egg-info/entry_points.txt +0 -0
  96. {b24api-2.1.0 → b24api-2.2.0}/b24api.egg-info/requires.txt +0 -0
  97. {b24api-2.1.0 → b24api-2.2.0}/b24api.egg-info/top_level.txt +0 -0
  98. {b24api-2.1.0 → b24api-2.2.0}/pyproject.toml +0 -0
  99. {b24api-2.1.0 → b24api-2.2.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: b24api
3
- Version: 2.1.0
3
+ Version: 2.2.0
4
4
  Summary: Bitrix24 API
5
5
  Requires-Python: >=3.12
6
6
  Description-Content-Type: text/markdown
@@ -139,20 +139,30 @@ more specialized mechanics have explicit names and explicit preconditions.
139
139
 
140
140
  | Operation | Use it when | Network mechanics | Completion proof |
141
141
  |---|---|---|---|
142
- | `iter_list` | The method supports ordinary offset pagination. | Pages are requested sequentially using server `next`; no separate count request is made. | Continuation and empty terminal page; add identity for duplicate detection. |
142
+ | `iter_list` | The method supports ordinary offset pagination. | Sequential requests follow server `next` (the next offset). Ordinary counted Bitrix list endpoints do server-side COUNT for `total` plus LIMIT/OFFSET page retrieval. | Continuation and empty terminal page; add identity for duplicate detection. |
143
143
  | `iter_list_counted` | The first response provides an exact filtered `total` and stable offset pages. | Head page is direct; all known tail offsets are grouped into physical Bitrix batches. | Exact total, ranges and identities. |
144
- | `iter_list_keyset` | The method may omit `total`, but reliably supports ordering and filtering by a unique integer identity. | Sequential by default; explicit range, partitioned, or auto execution may batch bounded work after a planning barrier. | Caller-asserted keyset contract, strict monotonic identity, bounded-plan canaries, and terminal empty confirmation. |
144
+ | `iter_list_keyset` | The method may omit `total`, but reliably supports ordering and filtering by a unique integer identity. | Auto by default: it plans first, then selects boundary-only, sequential, range, or partitioned execution; runtime sends no diagnostic canaries. | Caller-asserted keyset contract, strict monotonic identity, active bound validation, and terminal empty confirmation. |
145
145
  | `iter_list_cursor` | Each next request depends on a cursor from the previous response. | Sequential dependent cursor requests. | Strict unique monotonic cursor and empty terminal page. |
146
+ | `iter_cursors` | One or many parent-bound cursor traversals need correlation and shared batching. | Lazy per-parent drivers share the reference batch queue; each binding may have `start_cursor`. | Isolated strict cursor progress and terminal event per binding. |
146
147
  | `iter_references` | The same list method must run for many parent parameter sets, such as comments per owner or messages per chat. | Bindings are scheduled with direct or physical-batch dispatch; each binding has its own traversal state. | Per-binding rows, completion/failure and caller correlation. |
147
148
 
148
149
  `page_size` is a local decoded-page cap. It is sent to Bitrix only when you provide the endpoint's
149
150
  exact `limit_path`; the client never guesses method-specific parameter names.
150
151
 
152
+ ### List traversal comparison
153
+
154
+ ![List traversal comparison](list-traversal-comparison.svg)
155
+
151
156
  ### Sequential offset
152
157
 
153
158
  This is the canonical default. It follows the `next` returned by the server and confirms the end
154
- with an empty page. A `total` present in the response is observational; this strategy does not add
155
- a separate count request.
159
+ with an empty page. For ordinary counted Bitrix list endpoints, `next` is an offset for the next
160
+ LIMIT/OFFSET page, not a keyset cursor. Producing `total` involves a separate server-side count
161
+ query in addition to retrieving the page. These database operations are performed inside the same
162
+ REST request: the client does not issue an additional HTTP call just for the count. This distinction
163
+ matters for performance: not making a separate HTTP count call does **not** mean avoiding server-side
164
+ COUNT work. `iter_list` does not suppress that work; a returned `total` is observational and does not
165
+ control this strategy's completion. Exact database implementation is endpoint-specific.
156
166
 
157
167
  <!-- tested: tests/client_v2_test.py::test_iter_list_is_sequential_mechanics_only_and_report_is_post_cleanup -->
158
168
  ```python
@@ -216,25 +226,50 @@ stream = client.iter_list_counted(
216
226
  Use it only when `total` is exact for the supplied filter and offset pages are stable. Any missing
217
227
  range, overlap, duplicate identity or total contradiction raises `IncompleteTraversalError`.
218
228
 
229
+ Physical batching reduces HTTP exchanges, but does not suppress server-side COUNT in ordinary
230
+ counted list subrequests. Each command still performs its own offset page retrieval and associated
231
+ total calculation on the server. Do not confuse batching these commands with a no-count traversal.
232
+
219
233
  ### No-count keyset
220
234
 
221
- Keyset traversal is sequential by default, preserving the compatible request shape and first-pull
222
- behavior. An explicit execution contract can instead capture both ordered boundaries, validate five
223
- capability canaries, and batch numeric ranges or occupied-anchor partitions. Planning completes
224
- before any row is emitted, so partial consumption still pays that barrier cost. Fast execution fails
225
- synchronously when its declared controls or policy capacity are ineligible.
235
+ Keyset traversal uses automatic execution by default. Omitting `execution` is equivalent to
236
+ `AutoKeysetExecution(StableIntegerKeysetContract())`: the client captures both ordered boundaries,
237
+ then selects boundary-only, sequential, range, or partitioned execution from the observed geometry
238
+ and available policy capacity. Planning completes before any row is emitted, so partial consumption
239
+ still pays that barrier cost. A sequential selection made by auto is a cost decision; a failed or
240
+ contradictory fast plan is never silently restarted as sequential.
241
+
242
+ By using the default, the caller asserts that the endpoint has a stable, unique integer key, honors
243
+ strict numeric bounds and ordering, and satisfies empty-confirmation completion. Concurrent mutation
244
+ outside the captured middle is handled by the finishing sweep; mutation inside it is outside this
245
+ assertion. Pass `SequentialKeysetExecution()` explicitly when an endpoint cannot satisfy the fast
246
+ contract or when the previous request-by-request behavior is required.
247
+
248
+ Static incompatibility with the auto contract raises `CapabilityError` from the
249
+ `iter_list_keyset(...)` call before iteration begins. A portal that accepts but contradicts the
250
+ declared controls fails before emission with `IncompleteTraversalError`; auto never restarts that
251
+ operation silently. `KeysetTraversal` inside reference traversal remains sequential-only. The
252
+ terminal report now includes `keyset_execution` for omitted-execution keyset calls so consumers can
253
+ see the requested and selected plan.
254
+
255
+ Use `await client.verify_keyset_capability(...)` as a development/CI/staging guard on a stable
256
+ representative fixture. It performs five strict-bound checks and returns only a `VERIFIED` report;
257
+ unsupported and inconclusive verdicts raise `KeysetCapabilityError`. The ordinary
258
+ `iter_list_keyset()` remains a separate caller-asserted operation with zero verifier canaries and
259
+ may emit a partial prefix before a late endpoint contradiction is detected.
260
+
261
+ Every list operation also accepts an immutable `PageAdapter` strategy. The adapter synchronously
262
+ maps selected frozen items using sibling result metadata while preserving cardinality, order and
263
+ configured identities. The identity adapter is the default and preserves existing JSON output.
226
264
 
227
- The caller must assert that the endpoint has a stable, unique integer key, honors strict numeric
228
- bounds and ordering, and satisfies the chosen page-completion rule. Concurrent mutation outside the
229
- captured middle is handled by the finishing sweep; mutation inside it is outside this assertion.
230
265
  An advisory `total` may only raise an automatic cost estimate and never proves completion. Reports
231
266
  record the selected strategy and reason: unbounded auto continuation has the same
232
267
  `ordered_prefix_only` assurance as sequential traversal, while bounded plans additionally report
233
- `canary_verified_bounds`.
268
+ `caller_asserted_bounds`. Runtime traversal does not perform verifier canaries.
234
269
 
235
- <!-- tested: tests/client_v2_test.py::test_keyset_and_cursor_are_explicit_strict_alternatives -->
270
+ <!-- tested: tests/keyset_fast_test.py::test_omitted_execution_defaults_to_auto -->
236
271
  ```python
237
- from b24api import AutoKeysetExecution, KeysetSpec, ParameterPath, StableIntegerKeysetContract
272
+ from b24api import KeysetSpec, ParameterPath
238
273
 
239
274
  stream = client.iter_list_keyset(
240
275
  Request("example.item.list", replay_safety=ReplaySafety.SAFE),
@@ -244,7 +279,6 @@ stream = client.iter_list_keyset(
244
279
  filter_path=ParameterPath(("filter",)),
245
280
  order_path=ParameterPath(("order",)),
246
281
  ),
247
- execution=AutoKeysetExecution(contract=StableIntegerKeysetContract()),
248
282
  )
249
283
  ```
250
284
 
@@ -273,6 +307,11 @@ stream = client.iter_list_cursor(
273
307
  Cursor values must be unique and strictly monotonic. If an endpoint exposes only a non-unique
274
308
  boundary, use an application-owned direct-call workflow or supply a unique tie-breaker.
275
309
 
310
+ For multiple parent-bound cursor chains, `iter_cursors()` keeps cursor progress and correlation
311
+ isolated per binding while ready pages share the physical batch queue.
312
+
313
+ ![Cursor batching across independent chats](cursor-batching.svg)
314
+
276
315
  See [architecture](docs/architecture.md), [migration](docs/migration.md),
277
316
  [performance](docs/performance.md), and [endpoint recipes](docs/recipes.md) for the complete
278
317
  contracts and selection guidance.
@@ -283,6 +322,8 @@ contracts and selection guidance.
283
322
  client remains unaware of entity types: a binding can represent a deal, lead, chat or any other
284
323
  caller-defined parent.
285
324
 
325
+ ![Reference batching across leads and deals](references-batching.svg)
326
+
286
327
  <!-- tested: tests/client_v2_test.py::test_bound_references_apply_nested_updates_off_wire_and_emit_exact_completion -->
287
328
  ```python
288
329
  from b24api import (
@@ -127,20 +127,30 @@ more specialized mechanics have explicit names and explicit preconditions.
127
127
 
128
128
  | Operation | Use it when | Network mechanics | Completion proof |
129
129
  |---|---|---|---|
130
- | `iter_list` | The method supports ordinary offset pagination. | Pages are requested sequentially using server `next`; no separate count request is made. | Continuation and empty terminal page; add identity for duplicate detection. |
130
+ | `iter_list` | The method supports ordinary offset pagination. | Sequential requests follow server `next` (the next offset). Ordinary counted Bitrix list endpoints do server-side COUNT for `total` plus LIMIT/OFFSET page retrieval. | Continuation and empty terminal page; add identity for duplicate detection. |
131
131
  | `iter_list_counted` | The first response provides an exact filtered `total` and stable offset pages. | Head page is direct; all known tail offsets are grouped into physical Bitrix batches. | Exact total, ranges and identities. |
132
- | `iter_list_keyset` | The method may omit `total`, but reliably supports ordering and filtering by a unique integer identity. | Sequential by default; explicit range, partitioned, or auto execution may batch bounded work after a planning barrier. | Caller-asserted keyset contract, strict monotonic identity, bounded-plan canaries, and terminal empty confirmation. |
132
+ | `iter_list_keyset` | The method may omit `total`, but reliably supports ordering and filtering by a unique integer identity. | Auto by default: it plans first, then selects boundary-only, sequential, range, or partitioned execution; runtime sends no diagnostic canaries. | Caller-asserted keyset contract, strict monotonic identity, active bound validation, and terminal empty confirmation. |
133
133
  | `iter_list_cursor` | Each next request depends on a cursor from the previous response. | Sequential dependent cursor requests. | Strict unique monotonic cursor and empty terminal page. |
134
+ | `iter_cursors` | One or many parent-bound cursor traversals need correlation and shared batching. | Lazy per-parent drivers share the reference batch queue; each binding may have `start_cursor`. | Isolated strict cursor progress and terminal event per binding. |
134
135
  | `iter_references` | The same list method must run for many parent parameter sets, such as comments per owner or messages per chat. | Bindings are scheduled with direct or physical-batch dispatch; each binding has its own traversal state. | Per-binding rows, completion/failure and caller correlation. |
135
136
 
136
137
  `page_size` is a local decoded-page cap. It is sent to Bitrix only when you provide the endpoint's
137
138
  exact `limit_path`; the client never guesses method-specific parameter names.
138
139
 
140
+ ### List traversal comparison
141
+
142
+ ![List traversal comparison](list-traversal-comparison.svg)
143
+
139
144
  ### Sequential offset
140
145
 
141
146
  This is the canonical default. It follows the `next` returned by the server and confirms the end
142
- with an empty page. A `total` present in the response is observational; this strategy does not add
143
- a separate count request.
147
+ with an empty page. For ordinary counted Bitrix list endpoints, `next` is an offset for the next
148
+ LIMIT/OFFSET page, not a keyset cursor. Producing `total` involves a separate server-side count
149
+ query in addition to retrieving the page. These database operations are performed inside the same
150
+ REST request: the client does not issue an additional HTTP call just for the count. This distinction
151
+ matters for performance: not making a separate HTTP count call does **not** mean avoiding server-side
152
+ COUNT work. `iter_list` does not suppress that work; a returned `total` is observational and does not
153
+ control this strategy's completion. Exact database implementation is endpoint-specific.
144
154
 
145
155
  <!-- tested: tests/client_v2_test.py::test_iter_list_is_sequential_mechanics_only_and_report_is_post_cleanup -->
146
156
  ```python
@@ -204,25 +214,50 @@ stream = client.iter_list_counted(
204
214
  Use it only when `total` is exact for the supplied filter and offset pages are stable. Any missing
205
215
  range, overlap, duplicate identity or total contradiction raises `IncompleteTraversalError`.
206
216
 
217
+ Physical batching reduces HTTP exchanges, but does not suppress server-side COUNT in ordinary
218
+ counted list subrequests. Each command still performs its own offset page retrieval and associated
219
+ total calculation on the server. Do not confuse batching these commands with a no-count traversal.
220
+
207
221
  ### No-count keyset
208
222
 
209
- Keyset traversal is sequential by default, preserving the compatible request shape and first-pull
210
- behavior. An explicit execution contract can instead capture both ordered boundaries, validate five
211
- capability canaries, and batch numeric ranges or occupied-anchor partitions. Planning completes
212
- before any row is emitted, so partial consumption still pays that barrier cost. Fast execution fails
213
- synchronously when its declared controls or policy capacity are ineligible.
223
+ Keyset traversal uses automatic execution by default. Omitting `execution` is equivalent to
224
+ `AutoKeysetExecution(StableIntegerKeysetContract())`: the client captures both ordered boundaries,
225
+ then selects boundary-only, sequential, range, or partitioned execution from the observed geometry
226
+ and available policy capacity. Planning completes before any row is emitted, so partial consumption
227
+ still pays that barrier cost. A sequential selection made by auto is a cost decision; a failed or
228
+ contradictory fast plan is never silently restarted as sequential.
229
+
230
+ By using the default, the caller asserts that the endpoint has a stable, unique integer key, honors
231
+ strict numeric bounds and ordering, and satisfies empty-confirmation completion. Concurrent mutation
232
+ outside the captured middle is handled by the finishing sweep; mutation inside it is outside this
233
+ assertion. Pass `SequentialKeysetExecution()` explicitly when an endpoint cannot satisfy the fast
234
+ contract or when the previous request-by-request behavior is required.
235
+
236
+ Static incompatibility with the auto contract raises `CapabilityError` from the
237
+ `iter_list_keyset(...)` call before iteration begins. A portal that accepts but contradicts the
238
+ declared controls fails before emission with `IncompleteTraversalError`; auto never restarts that
239
+ operation silently. `KeysetTraversal` inside reference traversal remains sequential-only. The
240
+ terminal report now includes `keyset_execution` for omitted-execution keyset calls so consumers can
241
+ see the requested and selected plan.
242
+
243
+ Use `await client.verify_keyset_capability(...)` as a development/CI/staging guard on a stable
244
+ representative fixture. It performs five strict-bound checks and returns only a `VERIFIED` report;
245
+ unsupported and inconclusive verdicts raise `KeysetCapabilityError`. The ordinary
246
+ `iter_list_keyset()` remains a separate caller-asserted operation with zero verifier canaries and
247
+ may emit a partial prefix before a late endpoint contradiction is detected.
248
+
249
+ Every list operation also accepts an immutable `PageAdapter` strategy. The adapter synchronously
250
+ maps selected frozen items using sibling result metadata while preserving cardinality, order and
251
+ configured identities. The identity adapter is the default and preserves existing JSON output.
214
252
 
215
- The caller must assert that the endpoint has a stable, unique integer key, honors strict numeric
216
- bounds and ordering, and satisfies the chosen page-completion rule. Concurrent mutation outside the
217
- captured middle is handled by the finishing sweep; mutation inside it is outside this assertion.
218
253
  An advisory `total` may only raise an automatic cost estimate and never proves completion. Reports
219
254
  record the selected strategy and reason: unbounded auto continuation has the same
220
255
  `ordered_prefix_only` assurance as sequential traversal, while bounded plans additionally report
221
- `canary_verified_bounds`.
256
+ `caller_asserted_bounds`. Runtime traversal does not perform verifier canaries.
222
257
 
223
- <!-- tested: tests/client_v2_test.py::test_keyset_and_cursor_are_explicit_strict_alternatives -->
258
+ <!-- tested: tests/keyset_fast_test.py::test_omitted_execution_defaults_to_auto -->
224
259
  ```python
225
- from b24api import AutoKeysetExecution, KeysetSpec, ParameterPath, StableIntegerKeysetContract
260
+ from b24api import KeysetSpec, ParameterPath
226
261
 
227
262
  stream = client.iter_list_keyset(
228
263
  Request("example.item.list", replay_safety=ReplaySafety.SAFE),
@@ -232,7 +267,6 @@ stream = client.iter_list_keyset(
232
267
  filter_path=ParameterPath(("filter",)),
233
268
  order_path=ParameterPath(("order",)),
234
269
  ),
235
- execution=AutoKeysetExecution(contract=StableIntegerKeysetContract()),
236
270
  )
237
271
  ```
238
272
 
@@ -261,6 +295,11 @@ stream = client.iter_list_cursor(
261
295
  Cursor values must be unique and strictly monotonic. If an endpoint exposes only a non-unique
262
296
  boundary, use an application-owned direct-call workflow or supply a unique tie-breaker.
263
297
 
298
+ For multiple parent-bound cursor chains, `iter_cursors()` keeps cursor progress and correlation
299
+ isolated per binding while ready pages share the physical batch queue.
300
+
301
+ ![Cursor batching across independent chats](cursor-batching.svg)
302
+
264
303
  See [architecture](docs/architecture.md), [migration](docs/migration.md),
265
304
  [performance](docs/performance.md), and [endpoint recipes](docs/recipes.md) for the complete
266
305
  contracts and selection guidance.
@@ -271,6 +310,8 @@ contracts and selection guidance.
271
310
  client remains unaware of entity types: a binding can represent a deal, lead, chat or any other
272
311
  caller-defined parent.
273
312
 
313
+ ![Reference batching across leads and deals](references-batching.svg)
314
+
274
315
  <!-- tested: tests/client_v2_test.py::test_bound_references_apply_nested_updates_off_wire_and_emit_exact_completion -->
275
316
  ```python
276
317
  from b24api import (
@@ -2,6 +2,7 @@
2
2
 
3
3
  from b24api.client import Bitrix24
4
4
  from b24api.contracts import (
5
+ AdaptedPage,
5
6
  AmbiguityPolicy,
6
7
  AmbiguityReason,
7
8
  AutoKeysetExecution,
@@ -25,27 +26,39 @@ from b24api.contracts import (
25
26
  DeliveryOrder,
26
27
  DirectDispatch,
27
28
  ExecutionPolicy,
29
+ FrozenJson,
30
+ FrozenMapping,
28
31
  IdentityCoercion,
29
32
  IdentityComponent,
33
+ IdentityPageAdapter,
30
34
  IdentitySpec,
31
35
  KeysetAssuranceSource,
36
+ KeysetCapabilityCheckName,
37
+ KeysetCapabilityCheckOutcome,
38
+ KeysetCapabilityCheckResult,
39
+ KeysetCapabilityReport,
40
+ KeysetCapabilityVerdict,
32
41
  KeysetExecution,
33
42
  KeysetExecutionKind,
34
43
  KeysetExecutionReport,
44
+ KeysetInconclusiveReason,
35
45
  KeysetPageCompletion,
36
46
  KeysetPhase,
37
47
  KeysetSelectionReason,
38
48
  KeysetSpec,
39
49
  KeysetTraversal,
50
+ MembershipRecheck,
40
51
  NotExecutedReason,
41
52
  OffsetContinuation,
42
53
  OffsetSpec,
43
54
  OperationReport,
44
55
  OperationStream,
56
+ PageAdapter,
45
57
  PageDispatch,
46
58
  PageOutcome,
47
59
  PageRecord,
48
60
  PageRejectionCode,
61
+ PageView,
49
62
  ParameterPath,
50
63
  ParameterUpdate,
51
64
  PartialResult,
@@ -99,6 +112,9 @@ from b24api.errors import (
99
112
  IdentityContractError,
100
113
  IncompleteTraversalError,
101
114
  InputSourceError,
115
+ KeysetCapabilityError,
116
+ PageAdaptationError,
117
+ PageAdaptationViolation,
102
118
  PaginationError,
103
119
  ProtocolError,
104
120
  ReferenceFailed,
@@ -110,6 +126,7 @@ from b24api.settings import Settings
110
126
  from b24api.transport import Transport, TransportCapabilities, WireRequest, WireResponse, WireTransport
111
127
 
112
128
  __all__ = [
129
+ "AdaptedPage",
113
130
  "AmbiguityPolicy",
114
131
  "AmbiguityReason",
115
132
  "AmbiguousExecutionError",
@@ -142,31 +159,46 @@ __all__ = [
142
159
  "DirectDispatch",
143
160
  "EnvelopeContractError",
144
161
  "ExecutionPolicy",
162
+ "FrozenJson",
163
+ "FrozenMapping",
145
164
  "HTTPGatewayError",
146
165
  "IdentityCoercion",
147
166
  "IdentityComponent",
148
167
  "IdentityContractError",
168
+ "IdentityPageAdapter",
149
169
  "IdentitySpec",
150
170
  "IncompleteTraversalError",
151
171
  "InputSourceError",
152
172
  "KeysetAssuranceSource",
173
+ "KeysetCapabilityCheckName",
174
+ "KeysetCapabilityCheckOutcome",
175
+ "KeysetCapabilityCheckResult",
176
+ "KeysetCapabilityError",
177
+ "KeysetCapabilityReport",
178
+ "KeysetCapabilityVerdict",
153
179
  "KeysetExecution",
154
180
  "KeysetExecutionKind",
155
181
  "KeysetExecutionReport",
182
+ "KeysetInconclusiveReason",
156
183
  "KeysetPageCompletion",
157
184
  "KeysetPhase",
158
185
  "KeysetSelectionReason",
159
186
  "KeysetSpec",
160
187
  "KeysetTraversal",
188
+ "MembershipRecheck",
161
189
  "NotExecutedReason",
162
190
  "OffsetContinuation",
163
191
  "OffsetSpec",
164
192
  "OperationReport",
165
193
  "OperationStream",
194
+ "PageAdaptationError",
195
+ "PageAdaptationViolation",
196
+ "PageAdapter",
166
197
  "PageDispatch",
167
198
  "PageOutcome",
168
199
  "PageRecord",
169
200
  "PageRejectionCode",
201
+ "PageView",
170
202
  "PaginationError",
171
203
  "ParameterPath",
172
204
  "ParameterUpdate",
@@ -3,7 +3,8 @@
3
3
  from __future__ import annotations
4
4
  from typing import TYPE_CHECKING
5
5
 
6
- from b24api.contracts.keyset_execution import KeysetExecution, SequentialKeysetExecution
6
+ from b24api.contracts.keyset_execution import AutoKeysetExecution, KeysetExecution, StableIntegerKeysetContract
7
+ from b24api.contracts.page import IdentityPageAdapter, PageAdapter
7
8
  from b24api.contracts.request import IdentitySpec, RequestLike, ResultSelector, TraversalIdentity, canonical_request
8
9
  from b24api.contracts.response import ResultCollectionShape
9
10
  from b24api.contracts.traversal import CursorSpec, KeysetSpec, OffsetSpec, TotalTermination
@@ -21,7 +22,8 @@ _ROOT_SELECTOR = ResultSelector.root()
21
22
  _DEFAULT_OFFSET = OffsetSpec()
22
23
  _DEFAULT_COUNTED_OFFSET = OffsetSpec(total_termination=TotalTermination.EXACT_QUALIFIED)
23
24
  _DEFAULT_KEYSET = KeysetSpec()
24
- _DEFAULT_SEQUENTIAL_KEYSET_EXECUTION = SequentialKeysetExecution()
25
+ _DEFAULT_AUTO_KEYSET_EXECUTION = AutoKeysetExecution(StableIntegerKeysetContract())
26
+ _IDENTITY_PAGE_ADAPTER = IdentityPageAdapter()
25
27
 
26
28
 
27
29
  class _TraversalFacade:
@@ -51,6 +53,7 @@ class _TraversalFacade:
51
53
  collection_shape: ResultCollectionShape = ResultCollectionShape.SEQUENCE,
52
54
  page_size: int = 50,
53
55
  offset: OffsetSpec = _DEFAULT_OFFSET,
56
+ page_adapter: PageAdapter = _IDENTITY_PAGE_ADAPTER,
54
57
  policy: ExecutionPolicy | None = None,
55
58
  ) -> OperationStream[JsonValue]:
56
59
  """Return conservative sequential offset/server-next traversal."""
@@ -66,6 +69,7 @@ class _TraversalFacade:
66
69
  collection_shape=collection_shape,
67
70
  page_size=page_size,
68
71
  offset=offset,
72
+ page_adapter=page_adapter,
69
73
  policy=policy or self._default_policy,
70
74
  deregister=self._discard_stream,
71
75
  audit_violations=(() if audit_violation is None else (audit_violation,)),
@@ -82,6 +86,7 @@ class _TraversalFacade:
82
86
  page_size: int = 50,
83
87
  batch_size: int | None = None,
84
88
  offset: OffsetSpec = _DEFAULT_COUNTED_OFFSET,
89
+ page_adapter: PageAdapter = _IDENTITY_PAGE_ADAPTER,
85
90
  policy: ExecutionPolicy | None = None,
86
91
  ) -> OperationStream[JsonValue]:
87
92
  """Return exact direct-head plus physically batched counted traversal."""
@@ -98,6 +103,7 @@ class _TraversalFacade:
98
103
  page_size=page_size,
99
104
  batch_size=batch_size,
100
105
  offset=offset,
106
+ page_adapter=page_adapter,
101
107
  policy=policy or self._default_policy,
102
108
  deregister=self._discard_stream,
103
109
  audit_violations=(() if audit_violation is None else (audit_violation,)),
@@ -113,10 +119,11 @@ class _TraversalFacade:
113
119
  collection_shape: ResultCollectionShape = ResultCollectionShape.SEQUENCE,
114
120
  page_size: int = 50,
115
121
  keyset: KeysetSpec = _DEFAULT_KEYSET,
116
- execution: KeysetExecution = _DEFAULT_SEQUENTIAL_KEYSET_EXECUTION,
122
+ execution: KeysetExecution = _DEFAULT_AUTO_KEYSET_EXECUTION,
123
+ page_adapter: PageAdapter = _IDENTITY_PAGE_ADAPTER,
117
124
  policy: ExecutionPolicy | None = None,
118
125
  ) -> OperationStream[JsonValue]:
119
- """Return exact sequential no-count keyset traversal."""
126
+ """Return automatic no-count keyset traversal with explicit execution override."""
120
127
  self._require_open()
121
128
  canonical = canonical_request(request)
122
129
  audit_violation = self._audit_unknown(canonical)
@@ -130,6 +137,7 @@ class _TraversalFacade:
130
137
  page_size=page_size,
131
138
  keyset=keyset,
132
139
  execution=execution,
140
+ page_adapter=page_adapter,
133
141
  policy=policy or self._default_policy,
134
142
  deregister=self._discard_stream,
135
143
  audit_violations=(() if audit_violation is None else (audit_violation,)),
@@ -145,6 +153,7 @@ class _TraversalFacade:
145
153
  identity: IdentitySpec | None = None,
146
154
  collection_shape: ResultCollectionShape = ResultCollectionShape.SEQUENCE,
147
155
  page_size: int = 50,
156
+ page_adapter: PageAdapter = _IDENTITY_PAGE_ADAPTER,
148
157
  policy: ExecutionPolicy | None = None,
149
158
  ) -> OperationStream[JsonValue]:
150
159
  """Return strict dependent cursor traversal with empty confirmation."""
@@ -160,6 +169,7 @@ class _TraversalFacade:
160
169
  identity=identity,
161
170
  collection_shape=collection_shape,
162
171
  page_size=page_size,
172
+ page_adapter=page_adapter,
163
173
  policy=policy or self._default_policy,
164
174
  deregister=self._discard_stream,
165
175
  audit_violations=(() if audit_violation is None else (audit_violation,)),
@@ -11,6 +11,7 @@ from typing import TYPE_CHECKING, NoReturn, TextIO
11
11
  from b24api import (
12
12
  B24ApiError,
13
13
  Bitrix24,
14
+ KeysetCapabilityError,
14
15
  ReplaySafety,
15
16
  Request,
16
17
  Response,
@@ -20,10 +21,12 @@ from b24api import (
20
21
  from b24api.cli_contract import (
21
22
  CliUsageError,
22
23
  ListContractRoute,
24
+ VerifyKeysetContractRoute,
23
25
  cli_request,
24
26
  default_contract,
25
27
  list_stream,
26
28
  parse_list_contract,
29
+ parse_verify_keyset_contract,
27
30
  read_json_source,
28
31
  )
29
32
 
@@ -37,6 +40,8 @@ _USAGE = 2
37
40
  _UNAVAILABLE = 3
38
41
  _CORRECTNESS = 4
39
42
  _OUTPUT_CLOSED = 5
43
+ _KEYSET_UNSUPPORTED = 6
44
+ _KEYSET_INCONCLUSIVE = 7
40
45
  _INTERRUPTED = 130
41
46
 
42
47
 
@@ -119,6 +124,10 @@ def _parser() -> argparse.ArgumentParser:
119
124
  help="traversal mechanics (default: sequential)",
120
125
  )
121
126
  listing.add_argument("--contract", help="closed v1 traversal contract as @file or -")
127
+ verify = subparsers.add_parser("verify-keyset", help="verify strict keyset bounds for one portal method")
128
+ verify.add_argument("method", help="Bitrix24 REST method name")
129
+ verify.add_argument("--params", help="JSON object, @file, or - for stdin")
130
+ verify.add_argument("--contract", required=True, help="closed v1 verifier contract as @file")
122
131
  return parser
123
132
 
124
133
 
@@ -168,7 +177,20 @@ async def _list(
168
177
  raise RuntimeError("list traversal did not complete successfully")
169
178
 
170
179
 
171
- def main( # noqa: PLR0911 - stable process-code boundary
180
+ async def _verify_keyset(request: Request, route: VerifyKeysetContractRoute, stdout: TextIO) -> None:
181
+ async with Bitrix24() as client:
182
+ report = await client.verify_keyset_capability(
183
+ request,
184
+ selector=route.selector,
185
+ identity=route.identity,
186
+ collection_shape=route.collection_shape,
187
+ page_size=route.page_size,
188
+ keyset=route.keyset,
189
+ )
190
+ _write_json(stdout, report.to_dict())
191
+
192
+
193
+ def main( # noqa: C901, PLR0911, PLR0912 - stable process-code boundary
172
194
  argv: Sequence[str] | None = None,
173
195
  *,
174
196
  stdin: TextIO | None = None,
@@ -185,11 +207,16 @@ def main( # noqa: PLR0911 - stable process-code boundary
185
207
  if args.command == "call":
186
208
  request = cli_request(args.method, params, ReplaySafety(args.replay_safety))
187
209
  asyncio.run(_call(args, request, output_stream))
188
- else:
210
+ elif args.command == "list":
189
211
  contract = default_contract(args.strategy, args.contract, input_stream)
190
212
  route = parse_list_contract(args.strategy, contract)
191
213
  request = cli_request(args.method, params, ReplaySafety.UNKNOWN)
192
214
  asyncio.run(_list(request, route, output_stream, error_stream))
215
+ else:
216
+ contract = default_contract("verify-keyset", args.contract, input_stream)
217
+ verify_route = parse_verify_keyset_contract(contract)
218
+ request = cli_request(args.method, params, ReplaySafety.UNKNOWN)
219
+ asyncio.run(_verify_keyset(request, verify_route, output_stream))
193
220
  except KeyboardInterrupt:
194
221
  return _INTERRUPTED
195
222
  except SystemExit as error:
@@ -199,6 +226,12 @@ def main( # noqa: PLR0911 - stable process-code boundary
199
226
  return _USAGE
200
227
  except BrokenPipeError:
201
228
  return _OUTPUT_CLOSED
229
+ except KeysetCapabilityError as error:
230
+ try:
231
+ _write_json(output_stream, error.report.to_dict())
232
+ except BrokenPipeError:
233
+ return _OUTPUT_CLOSED
234
+ return _KEYSET_UNSUPPORTED if error.verdict.value == "unsupported" else _KEYSET_INCONCLUSIVE
202
235
  except B24ApiError as error:
203
236
  _write_json(error_stream, _safe_error(error))
204
237
  return _CORRECTNESS
@@ -19,6 +19,7 @@ from b24api import (
19
19
  RangeKeysetExecution,
20
20
  ReplaySafety,
21
21
  Request,
22
+ ResultCollectionShape,
22
23
  ResultSelector,
23
24
  SequentialKeysetExecution,
24
25
  StableIntegerKeysetContract,
@@ -32,7 +33,7 @@ if TYPE_CHECKING:
32
33
 
33
34
  _CONTRACT_VERSION = 1
34
35
  _PORTAL_BATCH_CAP = 50
35
- _SEQUENTIAL_KEYSET_EXECUTION = SequentialKeysetExecution()
36
+ _DEFAULT_AUTO_KEYSET_EXECUTION = AutoKeysetExecution(StableIntegerKeysetContract())
36
37
 
37
38
 
38
39
  class CliUsageError(ValueError):
@@ -220,10 +221,10 @@ def _keyset(raw: object) -> KeysetSpec:
220
221
  def parse_keyset_execution(payload: Mapping[str, JsonValue] | None) -> KeysetExecution:
221
222
  """Parse the closed per-call keyset execution object."""
222
223
  if payload is None:
223
- return SequentialKeysetExecution()
224
+ return _DEFAULT_AUTO_KEYSET_EXECUTION
224
225
  if not isinstance(payload, dict):
225
226
  raise CliUsageError("execution must be an object")
226
- kind = payload.get("kind", "sequential")
227
+ kind = payload.get("kind", "auto")
227
228
  if not isinstance(kind, str):
228
229
  raise CliUsageError("execution.kind must be a string")
229
230
  if kind not in {"sequential", "range", "partitioned", "auto"}:
@@ -309,7 +310,42 @@ class ListContractRoute:
309
310
  identity: IdentitySpec | None
310
311
  page_size: int
311
312
  mechanics: OffsetSpec | KeysetSpec | CursorSpec
312
- execution: KeysetExecution = _SEQUENTIAL_KEYSET_EXECUTION
313
+ execution: KeysetExecution = _DEFAULT_AUTO_KEYSET_EXECUTION
314
+
315
+
316
+ @dataclass(frozen=True, slots=True)
317
+ class VerifyKeysetContractRoute:
318
+ """Fully validated explicit keyset-verifier contract."""
319
+
320
+ selector: ResultSelector
321
+ identity: IdentitySpec
322
+ keyset: KeysetSpec
323
+ collection_shape: ResultCollectionShape = ResultCollectionShape.SEQUENCE
324
+ page_size: int = _PORTAL_BATCH_CAP
325
+
326
+
327
+ def parse_verify_keyset_contract(contract: dict[str, object]) -> VerifyKeysetContractRoute:
328
+ """Validate the closed v1 verifier wire contract."""
329
+ _closed(
330
+ contract,
331
+ {"version", "selector", "identity", "keyset", "collection_shape", "page_size"},
332
+ label="verify-keyset contract",
333
+ )
334
+ selector, page_size = _common(contract, selector_required=True)
335
+ identity = _identity(contract.get("identity"), required=True)
336
+ try:
337
+ shape = ResultCollectionShape(
338
+ cast("str", contract.get("collection_shape", ResultCollectionShape.SEQUENCE.value)),
339
+ )
340
+ except (TypeError, ValueError) as error:
341
+ raise CliUsageError("collection_shape is invalid") from error
342
+ return VerifyKeysetContractRoute(
343
+ selector,
344
+ cast("IdentitySpec", identity),
345
+ _keyset(contract.get("keyset")),
346
+ shape,
347
+ page_size,
348
+ )
313
349
 
314
350
 
315
351
  def parse_list_contract(strategy: str, contract: dict[str, object]) -> ListContractRoute:
@@ -390,11 +426,13 @@ __all__ = [
390
426
  "CliUsageError",
391
427
  "KeysetExecutionJson",
392
428
  "ListContractRoute",
429
+ "VerifyKeysetContractRoute",
393
430
  "cli_request",
394
431
  "decode_one_object",
395
432
  "default_contract",
396
433
  "list_stream",
397
434
  "parse_keyset_execution",
398
435
  "parse_list_contract",
436
+ "parse_verify_keyset_contract",
399
437
  "read_json_source",
400
438
  ]