b24api 0.5.0__tar.gz → 0.6.1__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.
Potentially problematic release.
This version of b24api might be problematic. Click here for more details.
- {b24api-0.5.0/b24api.egg-info → b24api-0.6.1}/PKG-INFO +2 -1
- b24api-0.6.1/b24api/async_api.py +262 -0
- {b24api-0.5.0 → b24api-0.6.1}/b24api/base_api.py +132 -114
- {b24api-0.5.0 → b24api-0.6.1}/b24api/entity.py +45 -18
- {b24api-0.5.0 → b24api-0.6.1}/b24api/sync_api.py +48 -79
- b24api-0.6.1/b24api/type.py +4 -0
- {b24api-0.5.0 → b24api-0.6.1/b24api.egg-info}/PKG-INFO +2 -1
- {b24api-0.5.0 → b24api-0.6.1}/b24api.egg-info/requires.txt +1 -0
- {b24api-0.5.0 → b24api-0.6.1}/pyproject.toml +1 -0
- b24api-0.5.0/b24api/async_api.py +0 -221
- b24api-0.5.0/b24api/type.py +0 -5
- {b24api-0.5.0 → b24api-0.6.1}/LICENSE +0 -0
- {b24api-0.5.0 → b24api-0.6.1}/MANIFEST.in +0 -0
- {b24api-0.5.0 → b24api-0.6.1}/README.md +0 -0
- {b24api-0.5.0 → b24api-0.6.1}/b24api/__init__.py +0 -0
- {b24api-0.5.0 → b24api-0.6.1}/b24api/error.py +0 -0
- {b24api-0.5.0 → b24api-0.6.1}/b24api/query.py +0 -0
- {b24api-0.5.0 → b24api-0.6.1}/b24api/settings.py +0 -0
- {b24api-0.5.0 → b24api-0.6.1}/b24api.egg-info/SOURCES.txt +0 -0
- {b24api-0.5.0 → b24api-0.6.1}/b24api.egg-info/dependency_links.txt +0 -0
- {b24api-0.5.0 → b24api-0.6.1}/b24api.egg-info/top_level.txt +0 -0
- {b24api-0.5.0 → b24api-0.6.1}/setup.cfg +0 -0
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: b24api
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.6.1
|
|
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_requests, batched_payloads = batched_requests_, 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
|
|
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
|
|
78
|
-
# Using string keys to simplify errors extraction
|
|
79
|
-
|
|
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
|
|
91
|
-
|
|
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
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
-
|
|
162
|
-
tail_request.parameters.start = -1
|
|
163
|
-
tail_request.parameters.order = {"ID": "DESC"}
|
|
167
|
+
self.get_id = itemgetter(id_key)
|
|
164
168
|
|
|
165
|
-
|
|
169
|
+
self.id_from = f">{self.id_key}"
|
|
170
|
+
self.id_to = f"<{self.id_key}"
|
|
166
171
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
-
|
|
180
|
-
|
|
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
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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
|
-
|
|
189
|
-
|
|
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__( # noqa: PLR0913
|
|
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,
|
|
193
|
-
|
|
194
|
-
|
|
208
|
+
list_size: int,
|
|
209
|
+
batch_size: int,
|
|
210
|
+
with_payload: bool, # noqa: FBT001
|
|
211
|
+
) -> None:
|
|
195
212
|
request = ListRequest.model_validate(request)
|
|
196
213
|
|
|
197
214
|
select_ = request.parameters.select
|
|
@@ -208,84 +225,85 @@ 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
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
if id_from in update:
|
|
218
|
-
raise ValueError(
|
|
219
|
-
f"Filter parameters `{id_from}` is reserved in `reference_batched_no_count`",
|
|
220
|
-
)
|
|
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
|
|
221
234
|
|
|
222
|
-
|
|
223
|
-
tail_request.parameters.filter |= update
|
|
224
|
-
tail_request.parameters.start = -1
|
|
225
|
-
tail_request.parameters.order = {"ID": "ASC"}
|
|
235
|
+
self.get_id = itemgetter(id_key)
|
|
226
236
|
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
else:
|
|
230
|
-
yield tail_request
|
|
237
|
+
self.id_from = f">{self.id_key}"
|
|
238
|
+
self.id_to = f"<{self.id_key}"
|
|
231
239
|
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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)
|
|
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")
|
|
243
243
|
|
|
244
|
-
|
|
245
|
-
|
|
244
|
+
for update in self.updates:
|
|
245
|
+
yield self._updated_request(update)
|
|
246
246
|
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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)
|
|
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")
|
|
255
250
|
|
|
256
|
-
|
|
257
|
-
|
|
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
|
-
|
|
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
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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_request, body_result, body_payload = body_request_, body_result_, 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
|
+
yield from zip(result, [payload] * len(result), strict=False)
|
|
308
|
+
else:
|
|
309
|
+
yield from chain.from_iterable(results)
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
from datetime import datetime
|
|
2
|
-
from typing import
|
|
2
|
+
from typing import Any
|
|
3
3
|
|
|
4
|
-
from pydantic import BaseModel,
|
|
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
|
-
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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:
|
|
106
|
-
result_time:
|
|
107
|
-
result_error:
|
|
108
|
-
result_total:
|
|
109
|
-
result_next:
|
|
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: # noqa: ANN401
|
|
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,41 +28,48 @@ class SyncBitrix24(BaseBitrix24):
|
|
|
33
28
|
headers={"Content-Type": "application/json"},
|
|
34
29
|
json=request.model_dump(mode="json")["parameters"],
|
|
35
30
|
)
|
|
36
|
-
response = self.
|
|
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."""
|
|
49
57
|
batch_size = batch_size or self.settings.batch_size
|
|
50
58
|
|
|
51
|
-
for
|
|
59
|
+
for batched_requests_ in batched(requests, batch_size):
|
|
52
60
|
if with_payload:
|
|
53
|
-
batched_requests, batched_payloads = zip(*
|
|
61
|
+
batched_requests, batched_payloads = zip(*batched_requests_, strict=True)
|
|
54
62
|
else:
|
|
55
|
-
batched_payloads = None
|
|
63
|
+
batched_requests, batched_payloads = batched_requests_, 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
|
|
70
|
+
yield result, batched_payloads[i]
|
|
61
71
|
else:
|
|
62
|
-
yield
|
|
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
|
|
91
|
+
yield from head_response.list_result
|
|
90
92
|
|
|
91
|
-
tail_requests = self.
|
|
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
|
|
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
|
|
124
|
+
yield from head_response.list_result
|
|
123
125
|
|
|
124
|
-
tail_requests = self.
|
|
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
|
-
|
|
146
|
+
batched_helper = BatchedNoCountHelper(request, id_key, list_size, batch_size)
|
|
146
147
|
|
|
147
|
-
|
|
148
|
-
head_result, tail_result =
|
|
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 =
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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)
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: b24api
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.6.1
|
|
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
|
b24api-0.5.0/b24api/async_api.py
DELETED
|
@@ -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
|
b24api-0.5.0/b24api/type.py
DELETED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|