b24api 0.3.9__tar.gz → 0.4.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,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: b24api
3
- Version: 0.3.9
3
+ Version: 0.4.0
4
4
  Summary: Bitrix24 API
5
5
  Requires-Python: >=3.10
6
6
  Description-Content-Type: text/markdown
@@ -9,7 +9,7 @@ Requires-Dist: fast-depends>=2.4.12
9
9
  Requires-Dist: httpx[http2]>=0.28.1
10
10
  Requires-Dist: pydantic>=2.10.6
11
11
  Requires-Dist: pydantic-settings>=2.8.1
12
- Requires-Dist: retry>=0.9.2
12
+ Requires-Dist: tenacity>=9.1.2
13
13
  Dynamic: license-file
14
14
 
15
15
  # API client for Bitrix24
@@ -1,49 +1,50 @@
1
1
  import contextlib
2
- import json
3
2
  import logging
4
3
  from collections.abc import Generator, Iterable
5
- from functools import partial
6
4
  from itertools import chain, islice
7
5
  from operator import itemgetter
6
+ from typing import Any
8
7
 
9
8
  import h2.exceptions
10
9
  import httpx
11
10
  from fast_depends import inject
11
+ from httpx import Client
12
12
  from pydantic import ValidationError
13
- from retry import retry
13
+ from tenacity import Retrying, before_sleep_log, retry_if_exception_type, stop_after_attempt, wait_exponential
14
14
 
15
15
  from b24api.entity import ApiTypes, BatchResult, ErrorResponse, ListRequest, Request, Response
16
16
  from b24api.error import RetryApiResponseError, RetryHTTPStatusError
17
17
  from b24api.settings import ApiSettings
18
- from b24api.transport import HttpxClient
19
18
 
20
19
 
21
20
  class Bitrix24:
21
+ http: Client = Client(http2=True)
22
+
22
23
  @inject
23
- def __init__(self, client: HttpxClient, settings: ApiSettings) -> None:
24
- self.client = client
24
+ def __init__(self, settings: ApiSettings) -> None:
25
25
  self.settings = settings
26
26
  self.logger = logging.getLogger("b24api")
27
27
 
28
- retry_ = partial(
29
- retry,
30
- exceptions=(
31
- httpx.TransportError,
32
- h2.exceptions.ProtocolError,
33
- RetryHTTPStatusError,
34
- RetryApiResponseError,
28
+ self.http.timeout = self.settings.http_timeout
29
+
30
+ self.retry = Retrying(
31
+ retry=retry_if_exception_type(
32
+ (
33
+ httpx.TransportError,
34
+ h2.exceptions.ProtocolError,
35
+ RetryHTTPStatusError,
36
+ RetryApiResponseError,
37
+ ),
35
38
  ),
36
- tries=self.settings.retry_tries,
37
- delay=self.settings.retry_delay,
38
- backoff=self.settings.retry_backoff,
39
- logger=self.logger,
39
+ wait=wait_exponential(multiplier=self.settings.retry_delay, exp_base=self.settings.retry_backoff),
40
+ stop=stop_after_attempt(self.settings.retry_attempts),
41
+ before_sleep=before_sleep_log(self.logger, logging.WARNING),
42
+ reraise=True,
40
43
  )
41
- self._call_retry = retry_()(self._call)
42
- self._batch_retry = retry_()(self._batch)
43
44
 
44
45
  def call(self, request: Request | dict) -> ApiTypes:
45
- """Call any method (with retries) and return just `result`."""
46
- return self._call_retry(request).result
46
+ """Call any method (with retries) and return `result` from response."""
47
+ return self.retry(self._call, request).result
47
48
 
48
49
  def _call(self, request: Request | dict) -> Response:
49
50
  """Call any method and return full response."""
@@ -51,17 +52,17 @@ class Bitrix24:
51
52
 
52
53
  self.logger.debug("Sending request: %s", request)
53
54
 
54
- http_response = self.client.post(
55
+ http_response = self.http.post(
55
56
  f"{self.settings.webhook_url}{request.method}",
56
57
  headers={"Content-Type": "application/json"},
57
58
  json=request.model_dump(mode="json")["parameters"],
58
59
  )
59
60
 
60
- with contextlib.suppress(httpx.ResponseNotRead, json.JSONDecodeError, ValidationError):
61
- ErrorResponse.model_validate(http_response.json()).raise_error(self.settings.retry_errors)
61
+ with contextlib.suppress(httpx.ResponseNotRead, ValidationError):
62
+ ErrorResponse.model_validate_json(http_response.content).raise_error(self.settings.retry_errors)
62
63
 
63
64
  try:
64
- json_response = http_response.raise_for_status().json()
65
+ http_response.raise_for_status()
65
66
  except httpx.HTTPStatusError as error:
66
67
  if http_response.status_code in self.settings.retry_statuses:
67
68
  raise RetryHTTPStatusError(
@@ -71,30 +72,36 @@ class Bitrix24:
71
72
  ) from error
72
73
  raise
73
74
 
74
- with contextlib.suppress(ValidationError):
75
- ErrorResponse.model_validate(json_response).raise_error(self.settings.retry_errors)
76
-
77
- response = Response.model_validate(json_response)
78
-
75
+ response = Response.model_validate_json(http_response.content)
79
76
  self.logger.debug("Received response: %s", response)
80
77
 
81
78
  return response
82
79
 
83
80
  def batch(
84
81
  self,
85
- requests: Iterable[Request | dict],
82
+ requests: Iterable[Request | dict | tuple[Request | dict, Any]],
83
+ *,
86
84
  batch_size: int | None = None,
87
- ) -> Generator[ApiTypes, None, None]:
88
- """Call infinite sequence of methods within batches and return just `result`s."""
85
+ with_payload: bool = False,
86
+ ) -> Generator[ApiTypes | tuple[ApiTypes, Any]]:
87
+ """Call unlimited sequence of methods within batches and return `result` from responses."""
89
88
  batch_size = batch_size or self.settings.batch_size
90
89
 
91
90
  tail_requests = iter(requests)
92
91
  while batched_requests := list(islice(tail_requests, batch_size)):
93
- for response in self._batch_retry(batched_requests):
94
- yield response.result
92
+ if with_payload:
93
+ batched_requests, batched_payloads = zip(*batched_requests, strict=True)
94
+ else:
95
+ batched_payloads = None
96
+
97
+ for i, response in enumerate(self.retry(self._batch, batched_requests)):
98
+ if with_payload:
99
+ yield response.result, batched_payloads[i]
100
+ else:
101
+ yield response.result
95
102
 
96
103
  def _batch(self, requests: Iterable[Request | dict]) -> list[Response]:
97
- """Call batch of methods and return full responses."""
104
+ """Call limited batch of methods and return full responses."""
98
105
  commands = {f"_{i}": Request.model_validate(request) for i, request in enumerate(requests)}
99
106
  request = Request(
100
107
  method="batch",
@@ -138,8 +145,9 @@ class Bitrix24:
138
145
  def list_sequential(
139
146
  self,
140
147
  request: Request | dict,
148
+ *,
141
149
  list_size: int | None = None,
142
- ) -> Generator[ApiTypes, None, None]:
150
+ ) -> Generator[ApiTypes]:
143
151
  """Call `list` method and return full `result`.
144
152
 
145
153
  Slow (sequential tail) list gathering for methods without `filter` parameter (e.g. `department.get`).
@@ -150,7 +158,7 @@ class Bitrix24:
150
158
  head_request = request.model_copy(deep=True)
151
159
  head_request.parameters["start"] = 0
152
160
 
153
- head_response = self._call_retry(head_request)
161
+ head_response = self.retry(self._call, head_request)
154
162
  yield from self._fix_list_result(head_response.result)
155
163
 
156
164
  if head_response.next and head_response.next != list_size:
@@ -160,7 +168,7 @@ class Bitrix24:
160
168
  for start in range(list_size, total, list_size):
161
169
  tail_request = head_request.model_copy(deep=True)
162
170
  tail_request.parameters["start"] = start
163
- tail_response = self._call_retry(tail_request)
171
+ tail_response = self.retry(self._call, tail_request)
164
172
 
165
173
  if tail_response.next and tail_response.next != start + list_size:
166
174
  raise ValueError(
@@ -171,9 +179,10 @@ class Bitrix24:
171
179
  def list_batched(
172
180
  self,
173
181
  request: Request | dict,
182
+ *,
174
183
  list_size: int | None = None,
175
184
  batch_size: int | None = None,
176
- ) -> Generator[ApiTypes, None, None]:
185
+ ) -> Generator[ApiTypes]:
177
186
  """Call `list` method and return full `result`.
178
187
 
179
188
  Faster (batched tail) list gathering for methods without `filter` parameter (e.g. `department.get`).
@@ -185,20 +194,20 @@ class Bitrix24:
185
194
  head_request = request.model_copy(deep=True)
186
195
  head_request.parameters["start"] = 0
187
196
 
188
- head_response = self._call_retry(head_request)
197
+ head_response = self.retry(self._call, head_request)
189
198
  yield from self._fix_list_result(head_response.result)
190
199
 
191
200
  if head_response.next and head_response.next != list_size:
192
201
  raise ValueError(f"Expecting chunk size to be {list_size}. Got: {head_response.next}")
193
202
 
194
- def _tail_requests() -> Generator[Request, None, None]:
203
+ def _tail_requests() -> Generator[Request]:
195
204
  total = head_response.total or 0
196
205
  for start in range(list_size, total, list_size):
197
206
  tail_request = head_request.model_copy(deep=True)
198
207
  tail_request.parameters["start"] = start
199
208
  yield tail_request
200
209
 
201
- tail_responses = self.batch(_tail_requests(), batch_size)
210
+ tail_responses = self.batch(_tail_requests(), batch_size=batch_size)
202
211
  tail_responses = map(self._fix_list_result, tail_responses)
203
212
  tail_responses = chain.from_iterable(tail_responses)
204
213
  yield from tail_responses
@@ -206,10 +215,11 @@ class Bitrix24:
206
215
  def list_batched_no_count(
207
216
  self,
208
217
  request: ListRequest | dict,
218
+ *,
209
219
  id_key: str = "ID",
210
220
  list_size: int | None = None,
211
221
  batch_size: int | None = None,
212
- ) -> Generator[ApiTypes, None, None]:
222
+ ) -> Generator[ApiTypes]:
213
223
  """Call `list` method and return full `result`.
214
224
 
215
225
  Fastest (batched, no count) list gathering for methods with `filter` parameter (e.g. `crm.lead.list`).
@@ -232,7 +242,7 @@ class Bitrix24:
232
242
  )
233
243
 
234
244
  if request.parameters.order:
235
- raise ValueError("Ordering parameters are reserved `order`in `list_batched_no_count`")
245
+ raise ValueError("Ordering parameters are reserved in `list_batched_no_count`")
236
246
 
237
247
  head_request = request.model_copy(deep=True)
238
248
  head_request.parameters.start = -1
@@ -242,14 +252,14 @@ class Bitrix24:
242
252
  tail_request.parameters.start = -1
243
253
  tail_request.parameters.order = {"ID": "DESC"}
244
254
 
245
- head_tail_result = self.batch([head_request, tail_request])
255
+ head_tail_result = self.batch([head_request, tail_request], batch_size=batch_size)
246
256
  head_result, tail_result = tuple(map(self._fix_list_result, head_tail_result))
247
257
  yield from head_result
248
258
 
249
259
  max_head_id = max(map(int, map(get_id, head_result)), default=None)
250
260
  min_tail_id = min(map(int, map(get_id, tail_result)), default=None)
251
261
 
252
- def _body_requests() -> Generator[ListRequest, None, None]:
262
+ def _body_requests() -> Generator[ListRequest]:
253
263
  for start in range(max_head_id, min_tail_id, list_size):
254
264
  body_request = head_request.model_copy(deep=True)
255
265
  body_request.parameters.filter[id_from] = start
@@ -257,7 +267,7 @@ class Bitrix24:
257
267
  yield body_request
258
268
 
259
269
  if max_head_id and min_tail_id and max_head_id < min_tail_id:
260
- body = self.batch(_body_requests(), batch_size)
270
+ body = self.batch(_body_requests(), batch_size=batch_size)
261
271
  body = map(self._fix_list_result, body)
262
272
  body = chain.from_iterable(body)
263
273
  yield from body
@@ -269,12 +279,14 @@ class Bitrix24:
269
279
  def reference_batched_no_count(
270
280
  self,
271
281
  request: ListRequest | dict,
272
- updates: Iterable[dict],
282
+ updates: Iterable[dict | tuple[dict, Any]],
283
+ *,
273
284
  id_key: str = "ID",
274
285
  list_size: int | None = None,
275
286
  batch_size: int | None = None,
276
- ) -> Generator[ApiTypes, None, None]:
277
- """Call `list` method with reference `updates` to filter and return full `result`.
287
+ with_payload: bool = False,
288
+ ) -> Generator[ApiTypes | tuple[ApiTypes, Any]]:
289
+ """Call `list` method with reference `updates` for `filter` and return full `result`.
278
290
 
279
291
  Fastest (batched, no count) list gathering for methods with `filter` parameter and required `reference`
280
292
  (e.g. `crm.timeline.comment.list`).
@@ -299,8 +311,13 @@ class Bitrix24:
299
311
  if request.parameters.order:
300
312
  raise ValueError("Ordering parameters are reserved `order`in `reference_batched_no_count`")
301
313
 
302
- def _tail_requests() -> Generator[ListRequest, None, None]:
314
+ def _tail_requests() -> Generator[ListRequest | tuple[ListRequest, Any]]:
303
315
  for update in updates:
316
+ if with_payload:
317
+ update, payload = update
318
+ else:
319
+ payload = None
320
+
304
321
  if id_from in update:
305
322
  raise ValueError(
306
323
  f"Filter parameters `{id_from}` is reserved in `reference_batched_no_count`",
@@ -309,21 +326,37 @@ class Bitrix24:
309
326
  tail_request.parameters.filter |= update
310
327
  tail_request.parameters.start = -1
311
328
  tail_request.parameters.order = {"ID": "ASC"}
312
- yield tail_request
329
+
330
+ if with_payload:
331
+ yield tail_request, payload
332
+ else:
333
+ yield tail_request
313
334
 
314
335
  head_requests = []
315
336
  tail_requests = iter(_tail_requests())
316
337
  while body_requests := head_requests + list(islice(tail_requests, batch_size - len(head_requests))):
317
- body_results = self.batch(body_requests, batch_size)
338
+ if with_payload:
339
+ body_requests, body_payloads = zip(*body_requests, strict=True)
340
+ else:
341
+ body_payloads = None
342
+
343
+ body_results = self.batch(body_requests, batch_size=batch_size)
318
344
  body_results = map(self._fix_list_result, body_results)
319
345
 
320
346
  head_requests = []
321
- for body_request, body_result in zip(body_requests, body_results, strict=True):
347
+ for i, (body_request, body_result) in enumerate(zip(body_requests, body_results, strict=True)):
322
348
  if len(body_result) == list_size:
323
349
  max_id = max(map(int, map(get_id, body_result)), default=None)
324
350
  head_request = body_request.model_copy(deep=True)
325
351
  head_request.parameters.filter[id_from] = max_id
326
- head_requests.append(head_request)
352
+ if with_payload:
353
+ head_requests.append((head_request, body_payloads[i]))
354
+ else:
355
+ head_requests.append(head_request)
356
+
357
+ if with_payload:
358
+ body_payload = [body_payloads[i]] * len(body_result)
359
+ body_result = zip(body_result, body_payload, strict=True)
327
360
 
328
361
  yield from body_result
329
362
 
@@ -354,6 +387,6 @@ class Bitrix24:
354
387
  value = result[key]
355
388
 
356
389
  if not isinstance(value, list):
357
- raise TypeError(f"If `result` is a `dict`, expecting single `list` item. Got: {result}")
390
+ raise TypeError(f"If `result` is a `dict`, expecting single item to be a `list`. Got: {result}")
358
391
 
359
392
  return value
@@ -5,10 +5,7 @@ from b24api.type import ApiTypes
5
5
 
6
6
 
7
7
  def build_query(parameters: dict[int | str, ApiTypes], path: str = "%s") -> str:
8
- query = []
9
-
10
- if parameters is None:
11
- return ""
8
+ query: list[str] = []
12
9
 
13
10
  for key, value in parameters.items():
14
11
  if value is None:
@@ -8,14 +8,10 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
8
8
 
9
9
 
10
10
  class Settings(BaseSettings):
11
- model_config = SettingsConfigDict(
12
- env_prefix="bitrix24_api_",
13
- env_file=".env",
14
- extra="ignore",
15
- )
16
-
17
11
  webhook_url: HttpUrl
18
12
 
13
+ http_timeout: int = 30
14
+
19
15
  retry_statuses: list[int] = (
20
16
  codes.LOCKED,
21
17
  codes.TOO_EARLY,
@@ -27,15 +23,21 @@ class Settings(BaseSettings):
27
23
  )
28
24
  retry_errors: list[str] = ["query_limit_exceeded", "operation_time_limit"]
29
25
 
30
- retry_tries: int = 5
26
+ retry_attempts: int = 5
31
27
  retry_delay: float = 5
32
28
  retry_backoff: float = 2
33
29
 
34
30
  list_size: int = 50
35
31
  batch_size: int = 50
36
32
 
33
+ model_config = SettingsConfigDict(
34
+ env_prefix="bitrix24_api_",
35
+ env_file=".env",
36
+ extra="ignore",
37
+ )
38
+
37
39
 
38
- def api_settings(**kwargs: dict) -> Generator[Settings, None, None]:
40
+ def api_settings(**kwargs: dict) -> Generator[Settings]:
39
41
  settings = Settings(**kwargs)
40
42
  yield settings
41
43
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: b24api
3
- Version: 0.3.9
3
+ Version: 0.4.0
4
4
  Summary: Bitrix24 API
5
5
  Requires-Python: >=3.10
6
6
  Description-Content-Type: text/markdown
@@ -9,7 +9,7 @@ Requires-Dist: fast-depends>=2.4.12
9
9
  Requires-Dist: httpx[http2]>=0.28.1
10
10
  Requires-Dist: pydantic>=2.10.6
11
11
  Requires-Dist: pydantic-settings>=2.8.1
12
- Requires-Dist: retry>=0.9.2
12
+ Requires-Dist: tenacity>=9.1.2
13
13
  Dynamic: license-file
14
14
 
15
15
  # API client for Bitrix24
@@ -8,7 +8,6 @@ b24api/entity.py
8
8
  b24api/error.py
9
9
  b24api/query.py
10
10
  b24api/settings.py
11
- b24api/transport.py
12
11
  b24api/type.py
13
12
  b24api.egg-info/PKG-INFO
14
13
  b24api.egg-info/SOURCES.txt
@@ -2,4 +2,4 @@ fast-depends>=2.4.12
2
2
  httpx[http2]>=0.28.1
3
3
  pydantic>=2.10.6
4
4
  pydantic-settings>=2.8.1
5
- retry>=0.9.2
5
+ tenacity>=9.1.2
@@ -20,7 +20,7 @@ dependencies = [
20
20
  "httpx[http2]>=0.28.1",
21
21
  "pydantic>=2.10.6",
22
22
  "pydantic-settings>=2.8.1",
23
- "retry>=0.9.2",
23
+ "tenacity>=9.1.2",
24
24
  ]
25
25
 
26
26
  [dependency-groups]
@@ -1,14 +0,0 @@
1
- from collections.abc import Generator
2
- from typing import Annotated
3
-
4
- from fast_depends import Depends
5
- from httpx import Client
6
-
7
-
8
- def httpx_client() -> Generator[Client, None, None]:
9
- client = Client(http2=True, timeout=30)
10
-
11
- yield client
12
-
13
-
14
- HttpxClient = Annotated[Client, Depends(httpx_client)]
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes