b24api 0.5.0__tar.gz → 0.6.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.
@@ -1,10 +1,11 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: b24api
3
- Version: 0.5.0
3
+ Version: 0.6.0
4
4
  Summary: Bitrix24 API
5
5
  Requires-Python: >=3.12
6
6
  Description-Content-Type: text/markdown
7
7
  License-File: LICENSE
8
+ Requires-Dist: aioitertools>=0.12.0
8
9
  Requires-Dist: fast-depends>=2.4.12
9
10
  Requires-Dist: httpx[http2]>=0.28.1
10
11
  Requires-Dist: pydantic>=2.11.7
@@ -0,0 +1,262 @@
1
+ from collections.abc import AsyncGenerator, AsyncIterable, Iterable
2
+ from itertools import batched, islice
3
+ from typing import Any
4
+
5
+ from aioitertools.itertools import chain as achain
6
+ from httpx import AsyncClient
7
+ from tenacity import AsyncRetrying
8
+
9
+ from b24api.base_api import BaseBitrix24, BatchedNoCountHelper, ReferenceNoCountHelper
10
+ from b24api.entity import ApiTypes, ListRequest, Request, Response
11
+
12
+
13
+ class AsyncBitrix24(BaseBitrix24):
14
+ @staticmethod
15
+ def _http() -> type[AsyncClient]:
16
+ return AsyncClient
17
+
18
+ @staticmethod
19
+ def _retry() -> type[AsyncRetrying]:
20
+ return AsyncRetrying
21
+
22
+ async def _call(self, request: Request | dict) -> Response:
23
+ """Call any method and return full response."""
24
+ request = Request.model_validate(request)
25
+ self.logger.debug("Sending request: %s", request)
26
+
27
+ response = await self.http.post(
28
+ f"{self.settings.webhook_url}{request.method}",
29
+ headers={"Content-Type": "application/json"},
30
+ json=request.model_dump(mode="json")["parameters"],
31
+ )
32
+ response = self._validate_call_response(request, response)
33
+ self.logger.debug("Received response: %s", response)
34
+
35
+ return response
36
+
37
+ async def call(self, request: Request | dict) -> ApiTypes:
38
+ """Call any method (with retries) and return `result` from response."""
39
+ return (await self.retry(self._call, request)).result
40
+
41
+ async def _batch(self, requests: tuple[Request | dict, ...]) -> list[Response]:
42
+ """Call limited batch of methods and return full responses."""
43
+ commands, request = self._batch_requests(requests)
44
+
45
+ response = await self._call(request)
46
+
47
+ return self._batch_responses(commands, response)
48
+
49
+ async def batch(
50
+ self,
51
+ requests: Iterable[Request | dict | tuple[Request | dict, Any]],
52
+ *,
53
+ batch_size: int | None = None,
54
+ list_method: bool = False,
55
+ with_payload: bool = False,
56
+ ) -> AsyncGenerator[ApiTypes | tuple[ApiTypes, Any]]:
57
+ """Call unlimited sequence of methods within batches and return `result` from responses."""
58
+ batch_size = batch_size or self.settings.batch_size
59
+
60
+ for batched_requests in batched(requests, batch_size):
61
+ if with_payload:
62
+ batched_requests, batched_payloads = zip(*batched_requests, strict=True)
63
+ else:
64
+ batched_payloads = None
65
+
66
+ batched_responses = await self.retry(self._batch, batched_requests)
67
+
68
+ for i, response in enumerate(batched_responses):
69
+ result = response.list_result if list_method else response.result
70
+ if with_payload:
71
+ yield result, batched_payloads[i]
72
+ else:
73
+ yield result
74
+
75
+ async def list_sequential(
76
+ self,
77
+ request: Request | dict,
78
+ *,
79
+ list_size: int | None = None,
80
+ ) -> AsyncGenerator[ApiTypes]:
81
+ """Call `list` method and return full `result`.
82
+
83
+ Slow (sequential tail) list gathering for methods without `filter` parameter (e.g. `department.get`).
84
+ """
85
+ request = Request.model_validate(request)
86
+ list_size = list_size or self.settings.list_size
87
+
88
+ head_request = request.model_copy(deep=True)
89
+ head_request.parameters["start"] = 0
90
+
91
+ head_response = await self.retry(self._call, head_request)
92
+ for item in head_response.list_result:
93
+ yield item
94
+
95
+ tail_requests = self._list_tail_requests(head_request, head_response, list_size=list_size)
96
+ for tail_request in tail_requests:
97
+ tail_response = await self.retry(self._call, tail_request)
98
+
99
+ start = tail_request.parameters["start"]
100
+ if tail_response.next and tail_response.next != start + list_size:
101
+ raise ValueError(
102
+ f"Expecting next list chunk to start at {start + list_size}. Got: {tail_response.next}",
103
+ )
104
+
105
+ for item in tail_response.list_result:
106
+ yield item
107
+
108
+ async def list_batched(
109
+ self,
110
+ request: Request | dict,
111
+ *,
112
+ list_size: int | None = None,
113
+ batch_size: int | None = None,
114
+ ) -> AsyncGenerator[ApiTypes]:
115
+ """Call `list` method and return full `result`.
116
+
117
+ Faster (batched tail) list gathering for methods without `filter` parameter (e.g. `department.get`).
118
+ """
119
+ request = Request.model_validate(request)
120
+ list_size = list_size or self.settings.list_size
121
+ batch_size = batch_size or self.settings.batch_size
122
+
123
+ head_request = request.model_copy(deep=True)
124
+ head_request.parameters["start"] = 0
125
+
126
+ head_response = await self.retry(self._call, head_request)
127
+ for item in head_response.list_result:
128
+ yield item
129
+
130
+ tail_requests = self._list_tail_requests(head_request, head_response, list_size=list_size)
131
+ tail_responses = self.batch(tail_requests, batch_size=batch_size, list_method=True)
132
+ async for tail_response in tail_responses:
133
+ for item in tail_response:
134
+ yield item
135
+
136
+ async def list_batched_no_count(
137
+ self,
138
+ request: ListRequest | dict,
139
+ *,
140
+ id_key: str = "ID",
141
+ list_size: int | None = None,
142
+ batch_size: int | None = None,
143
+ ) -> AsyncGenerator[ApiTypes]:
144
+ """Call `list` method and return full `result`.
145
+
146
+ Fastest (batched, no count) list gathering for methods with `filter` parameter (e.g. `crm.lead.list`).
147
+ """
148
+ list_size = list_size or self.settings.list_size
149
+ batch_size = batch_size or self.settings.batch_size
150
+
151
+ batched_helper = BatchedNoCountHelper(request, id_key, list_size, batch_size)
152
+
153
+ boundary_requests = batched_helper.head_request(), batched_helper.tail_request()
154
+ head_result, tail_result = await self.retry(self._batch, boundary_requests)
155
+ for item in head_result.list_result:
156
+ yield item
157
+
158
+ body_requests = batched_helper.body_requests(head_result, tail_result)
159
+ body_results = self.batch(body_requests, batch_size=batch_size, list_method=True)
160
+ async for item in achain.from_iterable(body_results):
161
+ yield item
162
+
163
+ for item in batched_helper.tail_results(head_result, tail_result):
164
+ yield item
165
+
166
+ async def reference_batched_no_count( # noqa: PLR0913
167
+ self,
168
+ request: ListRequest | dict,
169
+ updates: Iterable[dict | tuple[dict, Any]] | AsyncIterable[dict | tuple[dict, Any]],
170
+ *,
171
+ id_key: str = "ID",
172
+ list_size: int | None = None,
173
+ batch_size: int | None = None,
174
+ with_payload: bool = False,
175
+ ) -> AsyncGenerator[ApiTypes | tuple[ApiTypes, Any]]:
176
+ """Call `list` method with reference `updates` for `filter` and return full `result`.
177
+
178
+ Fastest (batched, no count) list gathering for methods with `filter` parameter and required `reference`
179
+ (e.g. `crm.timeline.comment.list`).
180
+ """
181
+ list_size = list_size or self.settings.list_size
182
+ batch_size = batch_size or self.settings.batch_size
183
+
184
+ reference_helper = ReferenceNoCountHelper(request, updates, id_key, list_size, batch_size, with_payload)
185
+
186
+ if isinstance(updates, AsyncIterable):
187
+ async for item in self._reference_batched_no_count_async_updates(reference_helper):
188
+ yield item
189
+ else:
190
+ async for item in self._reference_batched_no_count_sync_updates(reference_helper):
191
+ yield item
192
+
193
+ async def _reference_batched_no_count_async_updates(
194
+ self,
195
+ reference_helper: ReferenceNoCountHelper,
196
+ ) -> AsyncGenerator[ApiTypes | tuple[ApiTypes, Any]]:
197
+ head_requests, body_requests = (), []
198
+ async for tail_request in reference_helper.atail_requests():
199
+ body_requests.append(tail_request)
200
+ if len(head_requests) + len(body_requests) < reference_helper.batch_size:
201
+ continue
202
+
203
+ body_results, head_requests = await self._reference_batched_no_count_async_updates_batch(
204
+ reference_helper,
205
+ body_requests,
206
+ head_requests,
207
+ )
208
+ body_requests = []
209
+ for item in reference_helper.body_results(body_results):
210
+ yield item
211
+
212
+ while head_requests or body_requests:
213
+ body_results, head_requests = await self._reference_batched_no_count_async_updates_batch(
214
+ reference_helper,
215
+ body_requests,
216
+ head_requests,
217
+ )
218
+ body_requests = []
219
+ for item in reference_helper.body_results(body_results):
220
+ yield item
221
+
222
+ async def _reference_batched_no_count_async_updates_batch(
223
+ self,
224
+ reference_helper: ReferenceNoCountHelper,
225
+ body_requests: list[ListRequest],
226
+ head_requests: tuple[ListRequest, ...],
227
+ ) -> tuple[list[ApiTypes], tuple[ListRequest, ...]]:
228
+ body_requests = head_requests + tuple(body_requests)
229
+ body_results = [
230
+ r
231
+ async for r in self.batch(
232
+ body_requests,
233
+ batch_size=reference_helper.batch_size,
234
+ list_method=True,
235
+ with_payload=reference_helper.with_payload,
236
+ )
237
+ ]
238
+ head_requests = reference_helper.head_requests(body_requests, body_results)
239
+
240
+ return body_results, head_requests
241
+
242
+ async def _reference_batched_no_count_sync_updates(
243
+ self,
244
+ reference_helper: ReferenceNoCountHelper,
245
+ ) -> AsyncGenerator[ApiTypes | tuple[ApiTypes, Any]]:
246
+ head_requests = ()
247
+ tail_requests = iter(reference_helper.tail_requests())
248
+ while body_requests := head_requests + tuple(
249
+ islice(tail_requests, reference_helper.batch_size - len(head_requests)),
250
+ ):
251
+ body_results = [
252
+ r
253
+ async for r in self.batch(
254
+ body_requests,
255
+ batch_size=reference_helper.batch_size,
256
+ list_method=True,
257
+ with_payload=reference_helper.with_payload,
258
+ )
259
+ ]
260
+ head_requests = reference_helper.head_requests(body_requests, body_results)
261
+ for item in reference_helper.body_results(body_results):
262
+ yield item
@@ -1,7 +1,8 @@
1
1
  import contextlib
2
2
  import logging
3
3
  from abc import ABC, abstractmethod
4
- from collections.abc import Generator, Iterable
4
+ from collections.abc import AsyncGenerator, AsyncIterable, Generator, Iterable
5
+ from itertools import chain
5
6
  from operator import itemgetter
6
7
  from typing import Any
7
8
 
@@ -21,6 +22,7 @@ from tenacity import (
21
22
  from b24api.entity import BatchResult, ErrorResponse, ListRequest, Request, Response
22
23
  from b24api.error import RetryApiResponseError, RetryHTTPStatusError
23
24
  from b24api.settings import ApiSettings
25
+ from b24api.type import ApiTypes
24
26
 
25
27
 
26
28
  class BaseBitrix24(ABC):
@@ -55,7 +57,7 @@ class BaseBitrix24(ABC):
55
57
  def _retry() -> type[Retrying | AsyncRetrying]:
56
58
  pass
57
59
 
58
- def _estimate_call_response(self, request: Request, response: httpx.Response) -> Response:
60
+ def _validate_call_response(self, request: Request, response: httpx.Response) -> Response:
59
61
  # Checking more informative errors first (content may exist with 5xx status)
60
62
  with contextlib.suppress(httpx.ResponseNotRead, ValidationError):
61
63
  ErrorResponse.model_validate_json(response.content).raise_error(request, self.settings.retry_errors)
@@ -74,9 +76,10 @@ class BaseBitrix24(ABC):
74
76
  return Response.model_validate_json(response.content)
75
77
 
76
78
  @staticmethod
77
- def _estimate_batch_requests(requests: Iterable[Request | dict]) -> tuple[dict[str, Request], Request]:
78
- # Using string keys to simplify errors extraction
79
- commands = {f"_{i}": Request.model_validate(request) for i, request in enumerate(requests)}
79
+ def _batch_requests(requests: tuple[Request | dict, ...]) -> tuple[dict[str, Request], Request]:
80
+ # Using string keys with equal length to keep requests order and simplify errors extraction
81
+ width = len(str(len(requests)))
82
+ commands = {f"_{i:0>{width}d}": Request.model_validate(request) for i, request in enumerate(requests)}
80
83
  request = Request(
81
84
  method="batch",
82
85
  parameters={
@@ -87,8 +90,12 @@ class BaseBitrix24(ABC):
87
90
 
88
91
  return commands, request
89
92
 
90
- def _estimate_batch_responses(self, commands: dict[str, Request], result: Any) -> list[Response]:
91
- result = BatchResult.model_validate(result)
93
+ def _batch_responses(
94
+ self,
95
+ commands: dict[str, Request],
96
+ response: Response,
97
+ ) -> list[Response]:
98
+ result = BatchResult.model_validate(response.result)
92
99
 
93
100
  responses = []
94
101
  for key, command in commands.items():
@@ -114,11 +121,10 @@ class BaseBitrix24(ABC):
114
121
  next=result.result_next.get(key, None),
115
122
  ),
116
123
  )
117
-
118
124
  return responses
119
125
 
120
126
  @staticmethod
121
- def _estimate_list_tail_requests(
127
+ def _list_tail_requests(
122
128
  head_request: Request,
123
129
  head_response: Response,
124
130
  *,
@@ -133,12 +139,11 @@ class BaseBitrix24(ABC):
133
139
  tail_request.parameters["start"] = start
134
140
  yield tail_request
135
141
 
136
- @staticmethod
137
- def _estimate_list_batched_no_count_boundary_requests(
138
- request: ListRequest | dict,
139
- id_key: str,
140
- ) -> tuple[ListRequest, ListRequest]:
142
+
143
+ class BatchedNoCountHelper:
144
+ def __init__(self, request: ListRequest | dict, id_key: str, list_size: int, batch_size: int) -> None:
141
145
  request = ListRequest.model_validate(request)
146
+
142
147
  select_ = request.parameters.select
143
148
  if "*" not in select_ and id_key not in select_:
144
149
  request.select.append(id_key)
@@ -154,44 +159,56 @@ class BaseBitrix24(ABC):
154
159
  if request.parameters.order:
155
160
  raise ValueError("Ordering parameters are reserved in `list_batched_no_count`")
156
161
 
157
- head_request = request.model_copy(deep=True)
158
- head_request.parameters.start = -1
159
- head_request.parameters.order = {"ID": "ASC"}
162
+ self.request = request
163
+ self.id_key = id_key
164
+ self.list_size = list_size
165
+ self.batch_size = batch_size
160
166
 
161
- tail_request = request.model_copy(deep=True)
162
- tail_request.parameters.start = -1
163
- tail_request.parameters.order = {"ID": "DESC"}
167
+ self.get_id = itemgetter(id_key)
164
168
 
165
- return head_request, tail_request
169
+ self.id_from = f">{self.id_key}"
170
+ self.id_to = f"<{self.id_key}"
166
171
 
167
- @staticmethod
168
- def _estimate_list_batched_no_count_body_requests(
169
- head_request: ListRequest,
170
- head_result: list,
171
- tail_result: list,
172
- id_key: str,
173
- list_size: int,
174
- ) -> Generator[ListRequest]:
175
- get_id = itemgetter(id_key)
176
- max_head_id = max(map(int, map(get_id, head_result)), default=None)
177
- min_tail_id = min(map(int, map(get_id, tail_result)), default=None)
172
+ def head_request(self) -> ListRequest:
173
+ request = self.request.model_copy(deep=True)
174
+ request.parameters.start = -1
175
+ request.parameters.order = {"ID": "ASC"}
176
+ return request
178
177
 
179
- if max_head_id and min_tail_id and max_head_id < min_tail_id:
180
- id_from, id_to = f">{id_key}", f"<{id_key}"
178
+ def tail_request(self) -> ListRequest:
179
+ request = self.request.model_copy(deep=True)
180
+ request.parameters.start = -1
181
+ request.parameters.order = {"ID": "DESC"}
182
+ return request
181
183
 
182
- for start in range(max_head_id, min_tail_id, list_size):
183
- body_request = head_request.model_copy(deep=True)
184
- body_request.parameters.filter[id_from] = start
185
- body_request.parameters.filter[id_to] = min(start + list_size + 1, min_tail_id)
184
+ def body_requests(self, head_result: Response, tail_result: Response) -> Generator[ListRequest]:
185
+ max_head_id = max(map(int, map(self.get_id, head_result.list_result)), default=None)
186
+ min_tail_id = min(map(int, map(self.get_id, tail_result.list_result)), default=None)
187
+
188
+ if max_head_id and min_tail_id and max_head_id < min_tail_id:
189
+ for start in range(max_head_id, min_tail_id, self.list_size):
190
+ body_request = self.head_request()
191
+ body_request.parameters.filter[self.id_from] = start
192
+ body_request.parameters.filter[self.id_to] = min(start + self.list_size + 1, min_tail_id)
186
193
  yield body_request
187
194
 
188
- @staticmethod
189
- def _estimate_reference_batched_no_count_tail_requests(
195
+ def tail_results(self, head_result: Response, tail_result: Response) -> Generator[ApiTypes]:
196
+ max_head_id = max(map(int, map(self.get_id, head_result.list_result)), default=None)
197
+ for item in reversed(tail_result.list_result):
198
+ if int(self.get_id(item)) > max_head_id:
199
+ yield item
200
+
201
+
202
+ class ReferenceNoCountHelper:
203
+ def __init__(
204
+ self,
190
205
  request: ListRequest | dict,
191
- updates: Iterable[dict | tuple[dict, Any]],
206
+ updates: Iterable[dict | tuple[dict, Any]] | AsyncIterable[dict | tuple[dict, Any]],
192
207
  id_key: str,
208
+ list_size: int,
209
+ batch_size: int,
193
210
  with_payload: bool,
194
- ) -> Generator[ListRequest | tuple[ListRequest, Any]]:
211
+ ) -> None:
195
212
  request = ListRequest.model_validate(request)
196
213
 
197
214
  select_ = request.parameters.select
@@ -208,84 +225,86 @@ class BaseBitrix24(ABC):
208
225
  if request.parameters.order:
209
226
  raise ValueError("Ordering parameters are reserved `order`in `reference_batched_no_count`")
210
227
 
211
- for update in updates:
212
- if with_payload:
213
- update, payload = update
214
- else:
215
- payload = None
228
+ self.request = request
229
+ self.updates = updates
230
+ self.id_key = id_key
231
+ self.list_size = list_size
232
+ self.batch_size = batch_size
233
+ self.with_payload = with_payload
216
234
 
217
- if id_from in update:
218
- raise ValueError(
219
- f"Filter parameters `{id_from}` is reserved in `reference_batched_no_count`",
220
- )
235
+ self.get_id = itemgetter(id_key)
221
236
 
222
- tail_request = request.model_copy(deep=True)
223
- tail_request.parameters.filter |= update
224
- tail_request.parameters.start = -1
225
- tail_request.parameters.order = {"ID": "ASC"}
237
+ self.id_from = f">{self.id_key}"
238
+ self.id_to = f"<{self.id_key}"
226
239
 
227
- if with_payload:
228
- yield tail_request, payload
229
- else:
230
- yield tail_request
240
+ def tail_requests(self) -> Generator[ListRequest | tuple[ListRequest, Any]]:
241
+ if isinstance(self.updates, AsyncIterable):
242
+ raise TypeError("Use `atail_requests` to get asynchronous tail requests")
231
243
 
232
- @staticmethod
233
- def _estimate_reference_batched_no_count_result_next(
234
- body_request: ListRequest,
235
- body_result: list,
236
- body_payload: Any,
237
- id_key: str,
238
- list_size: int,
239
- with_payload: bool,
240
- ) -> tuple[[list[ListRequest | tuple[ListRequest, Any]]], Iterable[Any | tuple[Any, Any]]]:
241
- id_from = f">{id_key}"
242
- get_id = itemgetter(id_key)
244
+ for update in self.updates:
245
+ yield self._updated_request(update)
243
246
 
244
- head_requests = []
245
- body_result = BaseBitrix24._fix_list_result(body_result)
247
+ async def atail_requests(self) -> AsyncGenerator[ListRequest | tuple[ListRequest, Any]]:
248
+ if isinstance(self.updates, Iterable):
249
+ raise TypeError("Use `atail_requests` to get asynchronous tail requests")
246
250
 
247
- if len(body_result) == list_size:
248
- max_id = max(map(int, map(get_id, body_result)), default=None)
249
- head_request = body_request.model_copy(deep=True)
250
- head_request.parameters.filter[id_from] = max_id
251
- if with_payload:
252
- head_requests.append((head_request, body_payload))
253
- else:
254
- head_requests.append(head_request)
255
-
256
- if with_payload:
257
- body_payload = [body_payload] * len(body_result)
258
- body_result = zip(body_result, body_payload, strict=True)
251
+ async for update in self.updates:
252
+ yield self._updated_request(update)
259
253
 
260
- return head_requests, body_result
254
+ def _updated_request(self, update: dict | tuple[dict, Any]) -> ListRequest | tuple[ListRequest, Any]:
255
+ if self.with_payload:
256
+ update, payload = update
257
+ else:
258
+ payload = None
261
259
 
262
- @staticmethod
263
- def _fix_list_result(result: list | dict[str, list]) -> list:
264
- """Fix `list` method result to `list of items` structure.
265
-
266
- There are two kinds of what `list` method `result` may contain:
267
- - a list of items (e.g. `department-get` and `disk.folder.getchildren`),
268
- - a dictionary with single item that contains the desired list of items
269
- (e.g. `tasks` in `tasks.task.list`).
270
- """
271
- if not isinstance(result, list | dict):
272
- raise TypeError(f"Expecting `result` to be a `list` or a `dict`. Got: {result}")
273
-
274
- if not result:
275
- return []
276
-
277
- if isinstance(result, list):
278
- return result
279
-
280
- if len(result) != 1:
281
- raise TypeError(
282
- f"If `result` is a `dict`, expecting single item. Got: {result}",
260
+ if self.id_from in update:
261
+ raise ValueError(
262
+ f"Filter parameters `{self.id_from}` is reserved in `reference_batched_no_count`",
283
263
  )
284
264
 
285
- key = next(iter(result))
286
- value = result[key]
287
-
288
- if not isinstance(value, list):
289
- raise TypeError(f"If `result` is a `dict`, expecting single item to be a `list`. Got: {result}")
290
-
291
- return value
265
+ tail_request = self.request.model_copy(deep=True)
266
+ tail_request.parameters.filter |= update
267
+ tail_request.parameters.start = -1
268
+ tail_request.parameters.order = {"ID": "ASC"}
269
+
270
+ if self.with_payload:
271
+ return tail_request, payload
272
+
273
+ return tail_request
274
+
275
+ def head_requests(
276
+ self,
277
+ body_requests: tuple[ListRequest | tuple[ListRequest, Any], ...],
278
+ body_results: list[ApiTypes | tuple[ApiTypes, Any]],
279
+ ) -> tuple[ListRequest | tuple[ListRequest, Any], ...]:
280
+ requests = []
281
+ for body_request, body_result in zip(body_requests, body_results, strict=True):
282
+ if self.with_payload:
283
+ body_request, body_payload = body_request
284
+ body_result, _ = body_result
285
+ else:
286
+ body_payload = None
287
+
288
+ id_from = f">{self.id_key}"
289
+
290
+ if len(body_result) == self.list_size:
291
+ max_id = max(map(int, map(self.get_id, body_result)))
292
+ head_request = body_request.model_copy(deep=True)
293
+ head_request.parameters.filter[id_from] = max_id
294
+ if self.with_payload:
295
+ requests.append((head_request, body_payload))
296
+ else:
297
+ requests.append(head_request)
298
+
299
+ return tuple(requests)
300
+
301
+ def body_results(
302
+ self,
303
+ results: list[ApiTypes | tuple[ApiTypes, Any]],
304
+ ) -> Generator[ApiTypes | tuple[ApiTypes, Any]]:
305
+ if self.with_payload:
306
+ for result, payload in results:
307
+ result = zip(result, [payload] * len(result), strict=False)
308
+ yield from result
309
+ else:
310
+ yield from chain.from_iterable(results)
@@ -1,7 +1,7 @@
1
1
  from datetime import datetime
2
- from typing import Annotated, Any
2
+ from typing import Any
3
3
 
4
- from pydantic import BaseModel, BeforeValidator, field_validator
4
+ from pydantic import BaseModel, field_validator
5
5
 
6
6
  from b24api.error import ApiResponseError, RetryApiResponseError
7
7
  from b24api.query import build_query
@@ -58,13 +58,8 @@ class ErrorResponse(BaseModel):
58
58
  return value
59
59
 
60
60
  def raise_error(self, request: Request, retry_errors: list[str]) -> None:
61
- if self.error in retry_errors:
62
- raise RetryApiResponseError(
63
- code=self.error,
64
- description=self.error_description,
65
- request=str(request),
66
- )
67
- raise ApiResponseError(
61
+ error_cls = RetryApiResponseError if self.error in retry_errors else ApiResponseError
62
+ raise error_cls(
68
63
  code=self.error,
69
64
  description=self.error_description,
70
65
  request=str(request),
@@ -92,18 +87,50 @@ class Response(BaseModel):
92
87
  total: int | None = None
93
88
  next: int | None = None
94
89
 
90
+ @property
91
+ def list_result(self) -> list[ApiTypes]:
92
+ """Fix `list` methods result to `list of items` structure.
93
+
94
+ There are two kinds of what `list` method `result` may contain:
95
+ - a list of items (e.g. `department-get` and `disk.folder.getchildren`),
96
+ - a dictionary with single item that contains the desired list of items
97
+ (e.g. `tasks` in `tasks.task.list`).
98
+ """
99
+ if not isinstance(self.result, list | dict):
100
+ raise TypeError(f"Expecting `result` to be a `list` or a `dict`. Got: {self.result}")
101
+
102
+ if not self.result:
103
+ return []
104
+
105
+ if isinstance(self.result, list):
106
+ return self.result
107
+
108
+ if len(self.result) != 1:
109
+ raise TypeError(
110
+ f"If `result` is a `dict`, expecting single item. Got: {self.result}",
111
+ )
95
112
 
96
- def _php_dict(value: Any) -> Any: # noqa: ANN401
97
- if isinstance(value, list) and not value:
98
- return {}
99
- return value
113
+ key = next(iter(self.result))
114
+ value = self.result[key]
115
+
116
+ if not isinstance(value, list):
117
+ raise TypeError(f"If `result` is a `dict`, expecting single item to be a `list`. Got: {self.result}")
118
+
119
+ return value
100
120
 
101
121
 
102
122
  class BatchResult(BaseModel):
103
123
  """API response `result` structure for `batch` method."""
104
124
 
105
- result: Annotated[dict[str, ApiTypes], BeforeValidator(_php_dict)]
106
- result_time: Annotated[dict[str, ResponseTime], BeforeValidator(_php_dict)]
107
- result_error: Annotated[dict[str, ErrorResponse], BeforeValidator(_php_dict)]
108
- result_total: Annotated[dict[str, int], BeforeValidator(_php_dict)]
109
- result_next: Annotated[dict[str, int], BeforeValidator(_php_dict)]
125
+ result: dict[str, ApiTypes]
126
+ result_time: dict[str, ResponseTime]
127
+ result_error: dict[str, ErrorResponse]
128
+ result_total: dict[str, int]
129
+ result_next: dict[str, int]
130
+
131
+ @field_validator("result", "result_time", "result_error", "result_total", "result_next", mode="before")
132
+ @classmethod
133
+ def php_dict(cls, value: Any) -> Any:
134
+ if isinstance(value, list) and not value:
135
+ return {}
136
+ return value
@@ -1,12 +1,11 @@
1
1
  from collections.abc import Generator, Iterable
2
2
  from itertools import batched, chain, islice
3
- from operator import itemgetter
4
3
  from typing import Any
5
4
 
6
5
  from httpx import Client
7
6
  from tenacity import Retrying
8
7
 
9
- from b24api.base_api import BaseBitrix24
8
+ from b24api.base_api import BaseBitrix24, BatchedNoCountHelper, ReferenceNoCountHelper
10
9
  from b24api.entity import ApiTypes, ListRequest, Request, Response
11
10
 
12
11
 
@@ -19,10 +18,6 @@ class SyncBitrix24(BaseBitrix24):
19
18
  def _retry() -> type[Retrying]:
20
19
  return Retrying
21
20
 
22
- def call(self, request: Request | dict) -> ApiTypes:
23
- """Call any method (with retries) and return `result` from response."""
24
- return self.retry(self._call, request).result
25
-
26
21
  def _call(self, request: Request | dict) -> Response:
27
22
  """Call any method and return full response."""
28
23
  request = Request.model_validate(request)
@@ -33,16 +28,29 @@ class SyncBitrix24(BaseBitrix24):
33
28
  headers={"Content-Type": "application/json"},
34
29
  json=request.model_dump(mode="json")["parameters"],
35
30
  )
36
- response = self._estimate_call_response(request, response)
31
+ response = self._validate_call_response(request, response)
37
32
  self.logger.debug("Received response: %s", response)
38
33
 
39
34
  return response
40
35
 
36
+ def call(self, request: Request | dict) -> ApiTypes:
37
+ """Call any method (with retries) and return `result` from response."""
38
+ return self.retry(self._call, request).result
39
+
40
+ def _batch(self, requests: tuple[Request | dict, ...]) -> list[Response]:
41
+ """Call limited batch of methods and return full responses."""
42
+ commands, request = self._batch_requests(requests)
43
+
44
+ response = self._call(request)
45
+
46
+ return self._batch_responses(commands, response)
47
+
41
48
  def batch(
42
49
  self,
43
50
  requests: Iterable[Request | dict | tuple[Request | dict, Any]],
44
51
  *,
45
52
  batch_size: int | None = None,
53
+ list_method: bool = False,
46
54
  with_payload: bool = False,
47
55
  ) -> Generator[ApiTypes | tuple[ApiTypes, Any]]:
48
56
  """Call unlimited sequence of methods within batches and return `result` from responses."""
@@ -55,19 +63,13 @@ class SyncBitrix24(BaseBitrix24):
55
63
  batched_payloads = None
56
64
 
57
65
  batched_responses = self.retry(self._batch, batched_requests)
66
+
58
67
  for i, response in enumerate(batched_responses):
68
+ result = response.list_result if list_method else response.result
59
69
  if with_payload:
60
- yield response.result, batched_payloads[i]
70
+ yield result, batched_payloads[i]
61
71
  else:
62
- yield response.result
63
-
64
- def _batch(self, requests: Iterable[Request | dict]) -> list[Response]:
65
- """Call limited batch of methods and return full responses."""
66
- commands, request = self._estimate_batch_requests(requests)
67
-
68
- result = self._call(request).result
69
-
70
- return self._estimate_batch_responses(commands, result)
72
+ yield result
71
73
 
72
74
  def list_sequential(
73
75
  self,
@@ -86,9 +88,9 @@ class SyncBitrix24(BaseBitrix24):
86
88
  head_request.parameters["start"] = 0
87
89
 
88
90
  head_response = self.retry(self._call, head_request)
89
- yield from self._fix_list_result(head_response.result)
91
+ yield from head_response.list_result
90
92
 
91
- tail_requests = self._estimate_list_tail_requests(head_request, head_response, list_size=list_size)
93
+ tail_requests = self._list_tail_requests(head_request, head_response, list_size=list_size)
92
94
  for tail_request in tail_requests:
93
95
  tail_response = self.retry(self._call, tail_request)
94
96
 
@@ -98,7 +100,7 @@ class SyncBitrix24(BaseBitrix24):
98
100
  f"Expecting next list chunk to start at {start + list_size}. Got: {tail_response.next}",
99
101
  )
100
102
 
101
- yield from self._fix_list_result(tail_response.result)
103
+ yield from tail_response.list_result
102
104
 
103
105
  def list_batched(
104
106
  self,
@@ -119,11 +121,10 @@ class SyncBitrix24(BaseBitrix24):
119
121
  head_request.parameters["start"] = 0
120
122
 
121
123
  head_response = self.retry(self._call, head_request)
122
- yield from self._fix_list_result(head_response.result)
124
+ yield from head_response.list_result
123
125
 
124
- tail_requests = self._estimate_list_tail_requests(head_request, head_response, list_size=list_size)
125
- tail_responses = self.batch(tail_requests, batch_size=batch_size)
126
- tail_responses = map(self._fix_list_result, tail_responses)
126
+ tail_requests = self._list_tail_requests(head_request, head_response, list_size=list_size)
127
+ tail_responses = self.batch(tail_requests, batch_size=batch_size, list_method=True)
127
128
  tail_responses = chain.from_iterable(tail_responses)
128
129
  yield from tail_responses
129
130
 
@@ -142,31 +143,19 @@ class SyncBitrix24(BaseBitrix24):
142
143
  list_size = list_size or self.settings.list_size
143
144
  batch_size = batch_size or self.settings.batch_size
144
145
 
145
- head_request, tail_request = self._estimate_list_batched_no_count_boundary_requests(request, id_key)
146
+ batched_helper = BatchedNoCountHelper(request, id_key, list_size, batch_size)
146
147
 
147
- boundary_results = self.batch([head_request, tail_request], batch_size=batch_size)
148
- head_result, tail_result = tuple(map(self._fix_list_result, boundary_results))
149
- yield from head_result
148
+ boundary_requests = batched_helper.head_request(), batched_helper.tail_request()
149
+ head_result, tail_result = self.retry(self._batch, boundary_requests)
150
+ yield from head_result.list_result
150
151
 
151
- body_requests = self._estimate_list_batched_no_count_body_requests(
152
- head_request,
153
- head_result,
154
- tail_result,
155
- id_key,
156
- list_size,
157
- )
158
- body_results = self.batch(body_requests, batch_size=batch_size)
159
- body_results = map(self._fix_list_result, body_results)
160
- body_results = chain.from_iterable(body_results)
161
- yield from body_results
162
-
163
- get_id = itemgetter(id_key)
164
- max_head_id = max(map(int, map(get_id, head_result)), default=None)
165
- for item in reversed(tail_result):
166
- if int(get_id(item)) > max_head_id:
167
- yield item
168
-
169
- def reference_batched_no_count(
152
+ body_requests = batched_helper.body_requests(head_result, tail_result)
153
+ body_results = self.batch(body_requests, batch_size=batch_size, list_method=True)
154
+ yield from chain.from_iterable(body_results)
155
+
156
+ yield from batched_helper.tail_results(head_result, tail_result)
157
+
158
+ def reference_batched_no_count( # noqa: PLR0913
170
159
  self,
171
160
  request: ListRequest | dict,
172
161
  updates: Iterable[dict | tuple[dict, Any]],
@@ -184,32 +173,12 @@ class SyncBitrix24(BaseBitrix24):
184
173
  list_size = list_size or self.settings.list_size
185
174
  batch_size = batch_size or self.settings.batch_size
186
175
 
187
- head_requests = []
188
- tail_requests = iter(
189
- self._estimate_reference_batched_no_count_tail_requests(
190
- request,
191
- updates,
192
- id_key,
193
- with_payload,
194
- ),
195
- )
196
- while body_requests := head_requests + list(islice(tail_requests, batch_size - len(head_requests))):
197
- if with_payload:
198
- body_requests, body_payloads = zip(*body_requests, strict=True)
199
- else:
200
- body_payloads = None
201
-
202
- body_results = self.batch(body_requests, batch_size=batch_size)
203
-
204
- head_requests = []
205
- for i, (body_request, body_result) in enumerate(zip(body_requests, body_results, strict=True)):
206
- body_payload = body_payloads[i] if with_payload else None
207
- head_requests, body_result = self._estimate_reference_batched_no_count_result_next(
208
- body_request,
209
- body_result,
210
- body_payload,
211
- id_key,
212
- list_size,
213
- with_payload,
214
- )
215
- yield from body_result
176
+ reference_helper = ReferenceNoCountHelper(request, updates, id_key, list_size, batch_size, with_payload)
177
+
178
+ head_requests = ()
179
+ tail_requests = iter(reference_helper.tail_requests())
180
+ while body_requests := head_requests + tuple(islice(tail_requests, batch_size - len(head_requests))):
181
+ body_results = self.batch(body_requests, batch_size=batch_size, list_method=True, with_payload=with_payload)
182
+ body_results = list(body_results)
183
+ head_requests = reference_helper.head_requests(body_requests, body_results)
184
+ yield from reference_helper.body_results(body_results)
@@ -0,0 +1,4 @@
1
+ from datetime import datetime
2
+
3
+ # Types allowed in response and request
4
+ type ApiTypes = bool | str | int | float | dict | list | datetime | None
@@ -1,10 +1,11 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: b24api
3
- Version: 0.5.0
3
+ Version: 0.6.0
4
4
  Summary: Bitrix24 API
5
5
  Requires-Python: >=3.12
6
6
  Description-Content-Type: text/markdown
7
7
  License-File: LICENSE
8
+ Requires-Dist: aioitertools>=0.12.0
8
9
  Requires-Dist: fast-depends>=2.4.12
9
10
  Requires-Dist: httpx[http2]>=0.28.1
10
11
  Requires-Dist: pydantic>=2.11.7
@@ -1,3 +1,4 @@
1
+ aioitertools>=0.12.0
1
2
  fast-depends>=2.4.12
2
3
  httpx[http2]>=0.28.1
3
4
  pydantic>=2.11.7
@@ -16,6 +16,7 @@ description = "Bitrix24 API"
16
16
  readme = "README.md"
17
17
  requires-python = ">=3.12"
18
18
  dependencies = [
19
+ "aioitertools>=0.12.0",
19
20
  "fast-depends>=2.4.12",
20
21
  "httpx[http2]>=0.28.1",
21
22
  "pydantic>=2.11.7",
@@ -1,221 +0,0 @@
1
- from collections.abc import AsyncGenerator, Iterable
2
- from itertools import batched, islice
3
- from operator import itemgetter
4
- from typing import Any
5
-
6
- from httpx import AsyncClient
7
- from tenacity import AsyncRetrying
8
-
9
- from b24api.base_api import BaseBitrix24
10
- from b24api.entity import ApiTypes, ListRequest, Request, Response
11
-
12
-
13
- class AsyncBitrix24(BaseBitrix24):
14
- @staticmethod
15
- def _http() -> type[AsyncClient]:
16
- return AsyncClient
17
-
18
- @staticmethod
19
- def _retry() -> type[AsyncRetrying]:
20
- return AsyncRetrying
21
-
22
- async def call(self, request: Request | dict) -> ApiTypes:
23
- """Call any method (with retries) and return `result` from response."""
24
- return (await self.retry(self._call, request)).result
25
-
26
- async def _call(self, request: Request | dict) -> Response:
27
- """Call any method and return full response."""
28
- request = Request.model_validate(request)
29
- self.logger.debug("Sending request: %s", request)
30
-
31
- response = await self.http.post(
32
- f"{self.settings.webhook_url}{request.method}",
33
- headers={"Content-Type": "application/json"},
34
- json=request.model_dump(mode="json")["parameters"],
35
- )
36
- response = self._estimate_call_response(request, response)
37
- self.logger.debug("Received response: %s", response)
38
-
39
- return response
40
-
41
- async def batch(
42
- self,
43
- requests: Iterable[Request | dict | tuple[Request | dict, Any]],
44
- *,
45
- batch_size: int | None = None,
46
- with_payload: bool = False,
47
- ) -> AsyncGenerator[ApiTypes | tuple[ApiTypes, Any]]:
48
- """Call unlimited sequence of methods within batches and return `result` from responses."""
49
- batch_size = batch_size or self.settings.batch_size
50
-
51
- for batched_requests in batched(requests, batch_size):
52
- if with_payload:
53
- batched_requests, batched_payloads = zip(*batched_requests, strict=True)
54
- else:
55
- batched_payloads = None
56
-
57
- batched_responses = await self.retry(self._batch, batched_requests)
58
- for i, response in enumerate(batched_responses):
59
- if with_payload:
60
- yield response.result, batched_payloads[i]
61
- else:
62
- yield response.result
63
-
64
- async def _batch(self, requests: Iterable[Request | dict]) -> list[Response]:
65
- """Call limited batch of methods and return full responses."""
66
- commands, request = self._estimate_batch_requests(requests)
67
-
68
- result = (await self._call(request)).result
69
-
70
- return self._estimate_batch_responses(commands, result)
71
-
72
- async def list_sequential(
73
- self,
74
- request: Request | dict,
75
- *,
76
- list_size: int | None = None,
77
- ) -> AsyncGenerator[ApiTypes]:
78
- """Call `list` method and return full `result`.
79
-
80
- Slow (sequential tail) list gathering for methods without `filter` parameter (e.g. `department.get`).
81
- """
82
- request = Request.model_validate(request)
83
- list_size = list_size or self.settings.list_size
84
-
85
- head_request = request.model_copy(deep=True)
86
- head_request.parameters["start"] = 0
87
-
88
- head_response = await self.retry(self._call, head_request)
89
- for item in self._fix_list_result(head_response.result):
90
- yield item
91
-
92
- tail_requests = self._estimate_list_tail_requests(head_request, head_response, list_size=list_size)
93
- for tail_request in tail_requests:
94
- tail_response = await self.retry(self._call, tail_request)
95
-
96
- start = tail_request.parameters["start"]
97
- if tail_response.next and tail_response.next != start + list_size:
98
- raise ValueError(
99
- f"Expecting next list chunk to start at {start + list_size}. Got: {tail_response.next}",
100
- )
101
-
102
- for item in self._fix_list_result(tail_response.result):
103
- yield item
104
-
105
- async def list_batched(
106
- self,
107
- request: Request | dict,
108
- *,
109
- list_size: int | None = None,
110
- batch_size: int | None = None,
111
- ) -> AsyncGenerator[ApiTypes]:
112
- """Call `list` method and return full `result`.
113
-
114
- Faster (batched tail) list gathering for methods without `filter` parameter (e.g. `department.get`).
115
- """
116
- request = Request.model_validate(request)
117
- list_size = list_size or self.settings.list_size
118
- batch_size = batch_size or self.settings.batch_size
119
-
120
- head_request = request.model_copy(deep=True)
121
- head_request.parameters["start"] = 0
122
-
123
- head_response = await self.retry(self._call, head_request)
124
- for item in self._fix_list_result(head_response.result):
125
- yield item
126
-
127
- tail_requests = self._estimate_list_tail_requests(head_request, head_response, list_size=list_size)
128
- async for tail_response in self.batch(tail_requests, batch_size=batch_size):
129
- for item in self._fix_list_result(tail_response):
130
- yield item
131
-
132
- async def list_batched_no_count(
133
- self,
134
- request: ListRequest | dict,
135
- *,
136
- id_key: str = "ID",
137
- list_size: int | None = None,
138
- batch_size: int | None = None,
139
- ) -> AsyncGenerator[ApiTypes]:
140
- """Call `list` method and return full `result`.
141
-
142
- Fastest (batched, no count) list gathering for methods with `filter` parameter (e.g. `crm.lead.list`).
143
- """
144
- list_size = list_size or self.settings.list_size
145
- batch_size = batch_size or self.settings.batch_size
146
-
147
- head_request, tail_request = self._estimate_list_batched_no_count_boundary_requests(request, id_key)
148
-
149
- boundary_results = [r async for r in self.batch([head_request, tail_request], batch_size=batch_size)]
150
- head_result, tail_result = tuple(map(self._fix_list_result, boundary_results))
151
- for item in head_result:
152
- yield item
153
-
154
- body_requests = self._estimate_list_batched_no_count_body_requests(
155
- head_request,
156
- head_result,
157
- tail_result,
158
- id_key,
159
- list_size,
160
- )
161
- async for body_result in self.batch(body_requests, batch_size=batch_size):
162
- body_result = self._fix_list_result(body_result)
163
- for item in body_result:
164
- yield item
165
-
166
- get_id = itemgetter(id_key)
167
- max_head_id = max(map(int, map(get_id, head_result)), default=None)
168
- for item in reversed(tail_result):
169
- if int(get_id(item)) > max_head_id:
170
- yield item
171
-
172
- async def reference_batched_no_count(
173
- self,
174
- request: ListRequest | dict,
175
- updates: Iterable[dict | tuple[dict, Any]],
176
- *,
177
- id_key: str = "ID",
178
- list_size: int | None = None,
179
- batch_size: int | None = None,
180
- with_payload: bool = False,
181
- ) -> AsyncGenerator[ApiTypes | tuple[ApiTypes, Any]]:
182
- """Call `list` method with reference `updates` for `filter` and return full `result`.
183
-
184
- Fastest (batched, no count) list gathering for methods with `filter` parameter and required `reference`
185
- (e.g. `crm.timeline.comment.list`).
186
- """
187
- list_size = list_size or self.settings.list_size
188
- batch_size = batch_size or self.settings.batch_size
189
-
190
- head_requests = []
191
- tail_requests = iter(
192
- self._estimate_reference_batched_no_count_tail_requests(
193
- request,
194
- updates,
195
- id_key,
196
- with_payload,
197
- ),
198
- )
199
- while body_requests := head_requests + list(islice(tail_requests, batch_size - len(head_requests))):
200
- if with_payload:
201
- body_requests, body_payloads = zip(*body_requests, strict=True)
202
- else:
203
- body_payloads = None
204
-
205
- head_requests = []
206
-
207
- i = 0
208
- async for body_result in self.batch(body_requests, batch_size=batch_size):
209
- body_request = body_requests[i]
210
- body_payload = body_payloads[i] if with_payload else None
211
- head_requests, body_result = self._estimate_reference_batched_no_count_result_next(
212
- body_request,
213
- body_result,
214
- body_payload,
215
- id_key,
216
- list_size,
217
- with_payload,
218
- )
219
- for item in body_result:
220
- yield item
221
- i += 1
@@ -1,5 +0,0 @@
1
- from datetime import datetime
2
- from typing import TypeAlias
3
-
4
- # Types allowed in response and request
5
- ApiTypes: TypeAlias = bool | str | int | float | dict | list | datetime | None
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes