b24api 0.1.3__tar.gz → 0.3.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.
b24api-0.3.0/PKG-INFO ADDED
@@ -0,0 +1,93 @@
1
+ Metadata-Version: 2.4
2
+ Name: b24api
3
+ Version: 0.3.0
4
+ Summary: Bitrix24 API
5
+ Requires-Python: >=3.11
6
+ Description-Content-Type: text/markdown
7
+ License-File: LICENSE
8
+ Requires-Dist: fast-depends>=2.4.12
9
+ Requires-Dist: httpx[http2]>=0.28.1
10
+ Requires-Dist: pydantic>=2.10.6
11
+ Requires-Dist: pydantic-settings>=2.8.1
12
+ Requires-Dist: retry>=0.9.2
13
+ Dynamic: license-file
14
+
15
+ # API client for Bitrix24
16
+
17
+ Low-level API client with multiple strategies for lists gathering.
18
+ All methods support retries.
19
+
20
+ ## Regular call (any method)
21
+ ```python
22
+ from b24api import Bitrix24
23
+
24
+ b24 = Bitrix24()
25
+ result = b24.call({"method": "user.access", "parameters": {"ACCESS": ["G2", "AU"]}})
26
+ print(result)
27
+ ```
28
+
29
+ ## Batch call (any method, no limit)
30
+ ```python
31
+ from b24api import Bitrix24
32
+
33
+ b24 = Bitrix24()
34
+
35
+ requests = ({"method": "user.update", "parameters": {"ID": u, "UF_SKYPE": ""}} for u in range(1000))
36
+ result = b24.batch(requests)
37
+ print(result)
38
+ ```
39
+
40
+ ## Gathering full list (slow)
41
+ Applicable to list methods with `start=<offset>` support.
42
+ Fetches list chunks one by one.
43
+
44
+ ```python
45
+ from b24api import Bitrix24
46
+
47
+ b24 = Bitrix24()
48
+ result = b24.list_sequential({"method": "user.get"})
49
+ print(list(result))
50
+ ```
51
+
52
+ ## Gathering full list (faster)
53
+ Applicable to list methods with `start=<offset>` support.
54
+ Fetches first list chunk with regular call, then fetches other chunks with `batch`.
55
+ Approximately 3 times faster then `list_sequential`.
56
+
57
+ ```python
58
+ from b24api import Bitrix24
59
+
60
+ b24 = Bitrix24()
61
+ result = b24.list_batched({"method": "user.get"})
62
+ print(list(result))
63
+ ```
64
+
65
+ ## Gathering full list (fastest)
66
+ Applicable to list methods with `filter={<parameters>}` support.
67
+ Fetches first and last list chunk with batch call, then fetches other chunks with `batch`. Doesn't use counting (`start=-1`).
68
+ Approximately 2 times faster then `list_batched`.
69
+
70
+ ```python
71
+ from b24api import Bitrix24
72
+
73
+ b24 = Bitrix24()
74
+ result = b24.list_batched_no_count({"method": "user.get"})
75
+ print(list(result))
76
+ ```
77
+
78
+ ## Gathering full list with required reference
79
+ Applicable to list methods with `select=[<fields>]` and `filter={<parameters>}` support and required filter parameters.
80
+ Fetches first and last list chunk with batch call, then fetches other chunks with `batch`. Doesn't use counting (`start=-1`).
81
+
82
+ ```python
83
+ from b24api import Bitrix24
84
+
85
+ b24 = Bitrix24()
86
+ deal_ids = [1, 2, 3] # deals IDs (e.g. from "crm.deal.list" call)
87
+ filter_updates = ({"=ENTITY_ID": i} for i in deal_ids)
88
+ result = b24.reference_batched_no_count(
89
+ {"method": "crm.timeline.comment.list", "parameters": {"ENTITY_TYPE": "deal"}},
90
+ filter_updates,
91
+ )
92
+ print(list(result))
93
+ ```
b24api-0.3.0/README.md ADDED
@@ -0,0 +1,79 @@
1
+ # API client for Bitrix24
2
+
3
+ Low-level API client with multiple strategies for lists gathering.
4
+ All methods support retries.
5
+
6
+ ## Regular call (any method)
7
+ ```python
8
+ from b24api import Bitrix24
9
+
10
+ b24 = Bitrix24()
11
+ result = b24.call({"method": "user.access", "parameters": {"ACCESS": ["G2", "AU"]}})
12
+ print(result)
13
+ ```
14
+
15
+ ## Batch call (any method, no limit)
16
+ ```python
17
+ from b24api import Bitrix24
18
+
19
+ b24 = Bitrix24()
20
+
21
+ requests = ({"method": "user.update", "parameters": {"ID": u, "UF_SKYPE": ""}} for u in range(1000))
22
+ result = b24.batch(requests)
23
+ print(result)
24
+ ```
25
+
26
+ ## Gathering full list (slow)
27
+ Applicable to list methods with `start=<offset>` support.
28
+ Fetches list chunks one by one.
29
+
30
+ ```python
31
+ from b24api import Bitrix24
32
+
33
+ b24 = Bitrix24()
34
+ result = b24.list_sequential({"method": "user.get"})
35
+ print(list(result))
36
+ ```
37
+
38
+ ## Gathering full list (faster)
39
+ Applicable to list methods with `start=<offset>` support.
40
+ Fetches first list chunk with regular call, then fetches other chunks with `batch`.
41
+ Approximately 3 times faster then `list_sequential`.
42
+
43
+ ```python
44
+ from b24api import Bitrix24
45
+
46
+ b24 = Bitrix24()
47
+ result = b24.list_batched({"method": "user.get"})
48
+ print(list(result))
49
+ ```
50
+
51
+ ## Gathering full list (fastest)
52
+ Applicable to list methods with `filter={<parameters>}` support.
53
+ Fetches first and last list chunk with batch call, then fetches other chunks with `batch`. Doesn't use counting (`start=-1`).
54
+ Approximately 2 times faster then `list_batched`.
55
+
56
+ ```python
57
+ from b24api import Bitrix24
58
+
59
+ b24 = Bitrix24()
60
+ result = b24.list_batched_no_count({"method": "user.get"})
61
+ print(list(result))
62
+ ```
63
+
64
+ ## Gathering full list with required reference
65
+ Applicable to list methods with `select=[<fields>]` and `filter={<parameters>}` support and required filter parameters.
66
+ Fetches first and last list chunk with batch call, then fetches other chunks with `batch`. Doesn't use counting (`start=-1`).
67
+
68
+ ```python
69
+ from b24api import Bitrix24
70
+
71
+ b24 = Bitrix24()
72
+ deal_ids = [1, 2, 3] # deals IDs (e.g. from "crm.deal.list" call)
73
+ filter_updates = ({"=ENTITY_ID": i} for i in deal_ids)
74
+ result = b24.reference_batched_no_count(
75
+ {"method": "crm.timeline.comment.list", "parameters": {"ENTITY_TYPE": "deal"}},
76
+ filter_updates,
77
+ )
78
+ print(list(result))
79
+ ```
@@ -1,17 +1,18 @@
1
1
  import contextlib
2
+ import json
3
+ import logging
2
4
  from collections.abc import Generator, Iterable
3
- from itertools import chain
5
+ from itertools import chain, islice
4
6
  from operator import itemgetter
5
7
 
6
8
  import httpx
7
9
  from fast_depends import inject
8
10
  from pydantic import ValidationError
9
- from retry.api import retry_call
11
+ from retry import retry
10
12
 
11
- from b24api.entity import ApiTypes, BatchResult, ErrorResponse, ListRequest, Request, ResultResponse
13
+ from b24api.entity import ApiTypes, BatchResult, ErrorResponse, ListRequest, Request, Response
12
14
  from b24api.error import RetryApiResponseError, RetryHTTPStatusError
13
- from b24api.future import batched
14
- from b24api.http import HttpxClient
15
+ from b24api.httpc import HttpxClient
15
16
  from b24api.settings import ApiSettings
16
17
 
17
18
 
@@ -20,28 +21,36 @@ class Bitrix24:
20
21
  def __init__(self, client: HttpxClient, settings: ApiSettings) -> None:
21
22
  self.client = client
22
23
  self.settings = settings
23
-
24
- def call(self, request: Request) -> ApiTypes:
25
- """Call any method (with retries) and return just `result`."""
26
- response = retry_call(
27
- self._call,
28
- fargs=[request],
24
+ self.logger = logging.getLogger("b24api")
25
+ self._call_retry = retry(
29
26
  exceptions=(RetryHTTPStatusError, RetryApiResponseError),
30
27
  tries=self.settings.retry_tries,
31
28
  delay=self.settings.retry_delay,
32
29
  backoff=self.settings.retry_backoff,
33
- )
30
+ logger=self.logger,
31
+ )(self._call)
32
+
33
+ def call(self, request: Request | dict) -> ApiTypes:
34
+ """Call any method (with retries) and return just `result`."""
35
+ response = self._call_retry(request)
34
36
 
35
37
  return response.result
36
38
 
37
- def _call(self, request: Request) -> ResultResponse:
39
+ def _call(self, request: Request | dict) -> Response:
38
40
  """Call any method and return full response."""
41
+ request = Request.model_validate(request)
42
+
43
+ self.logger.debug("Sending request", extra={"request": request})
44
+
39
45
  http_response = self.client.post(
40
46
  f"{self.settings.webhook_url}{request.method}",
41
47
  headers={"Content-Type": "application/json"},
42
48
  json=request.model_dump()["parameters"],
43
49
  )
44
50
 
51
+ with contextlib.suppress(httpx.ResponseNotRead, json.JSONDecodeError, ValidationError):
52
+ ErrorResponse.model_validate(http_response.json()).raise_error(self.settings.retry_errors)
53
+
45
54
  try:
46
55
  json_response = http_response.raise_for_status().json()
47
56
  except httpx.HTTPStatusError as error:
@@ -56,23 +65,37 @@ class Bitrix24:
56
65
  with contextlib.suppress(ValidationError):
57
66
  ErrorResponse.model_validate(json_response).raise_error(self.settings.retry_errors)
58
67
 
59
- return ResultResponse.model_validate(json_response)
68
+ response = Response.model_validate(json_response)
69
+
70
+ self.logger.debug("Received response", extra={"request": request, "response": response})
71
+ self.logger.info(
72
+ "Received response",
73
+ extra={
74
+ "method": request.method,
75
+ "duration": response.time.duration,
76
+ "processing": response.time.processing,
77
+ "operating": response.time.operating,
78
+ },
79
+ )
80
+
81
+ return response
60
82
 
61
83
  def batch(
62
84
  self,
63
- requests: Iterable[Request],
85
+ requests: Iterable[Request | dict],
64
86
  batch_size: int | None = None,
65
87
  ) -> Generator[ApiTypes, None, None]:
66
88
  """Call infinite sequence of methods within batches and return just `result`s."""
67
89
  batch_size = batch_size or self.settings.batch_size
68
90
 
69
- for batched_requests in batched(requests, batch_size):
91
+ tail_requests = iter(requests)
92
+ while batched_requests := list(islice(tail_requests, batch_size)):
70
93
  for response in self._batch(batched_requests):
71
94
  yield response.result
72
95
 
73
- def _batch(self, requests: Iterable[Request]) -> Generator[ResultResponse, None, None]:
96
+ def _batch(self, requests: Iterable[Request | dict]) -> Generator[Response, None, None]:
74
97
  """Call batch of methods and return full responses."""
75
- commands = {f"_{i}": request for i, request in enumerate(requests)}
98
+ commands = {f"_{i}": Request.model_validate(request) for i, request in enumerate(requests)}
76
99
  request = Request(
77
100
  method="batch",
78
101
  parameters={
@@ -83,10 +106,6 @@ class Bitrix24:
83
106
 
84
107
  result = self.call(request)
85
108
 
86
- for fix_key in ["result_error", "result_total", "result_next"]:
87
- if isinstance(result[fix_key], list) and not result[fix_key]:
88
- result[fix_key] = dict(result[fix_key])
89
-
90
109
  result = BatchResult.model_validate(result)
91
110
 
92
111
  for i in range(len(commands)):
@@ -105,7 +124,7 @@ class Bitrix24:
105
124
  f"Expecting `result_time` to contain result for command {{`{key}`: {command}}}. Got: {result}",
106
125
  )
107
126
 
108
- yield ResultResponse(
127
+ yield Response(
109
128
  result=result.result[key],
110
129
  time=result.result_time[key],
111
130
  total=result.result_total.get(key, None),
@@ -114,17 +133,21 @@ class Bitrix24:
114
133
 
115
134
  def list_sequential(
116
135
  self,
117
- head_request: Request,
136
+ request: Request | dict,
118
137
  list_size: int | None = None,
119
138
  ) -> Generator[ApiTypes, None, None]:
120
139
  """Call `list` method and return full `result`.
121
140
 
122
141
  Slow (sequential tail) list gathering for methods without `filter` parameter (e.g. `department.get`).
123
142
  """
143
+ request = Request.model_validate(request)
124
144
  list_size = list_size or self.settings.list_size
125
145
 
126
- head_response = self._call(head_request)
127
- yield from self._normalize_list(head_response.result)
146
+ head_request = request.model_copy(deep=True)
147
+ head_request.parameters["start"] = 0
148
+
149
+ head_response = self._call_retry(head_request)
150
+ yield from self._fix_list_result(head_response.result)
128
151
 
129
152
  if head_response.next and head_response.next != list_size:
130
153
  raise ValueError(f"Expecting list chunk size to be {list_size}. Got: {head_response.next}")
@@ -132,19 +155,18 @@ class Bitrix24:
132
155
  total = head_response.total or 0
133
156
  for start in range(list_size, total, list_size):
134
157
  tail_request = head_request.model_copy(deep=True)
135
- tail_request.parameters |= {"start": start}
136
- tail_response = self._call(tail_request)
158
+ tail_request.parameters["start"] = start
159
+ tail_response = self._call_retry(tail_request)
137
160
 
138
161
  if tail_response.next and tail_response.next != start + list_size:
139
162
  raise ValueError(
140
163
  f"Expecting next list chunk to start at {start + list_size}. Got: {tail_response.next}",
141
164
  )
142
-
143
- yield from self._normalize_list(tail_response.result)
165
+ yield from self._fix_list_result(tail_response.result)
144
166
 
145
167
  def list_batched(
146
168
  self,
147
- head_request: Request,
169
+ request: Request | dict,
148
170
  list_size: int | None = None,
149
171
  batch_size: int | None = None,
150
172
  ) -> Generator[ApiTypes, None, None]:
@@ -152,11 +174,15 @@ class Bitrix24:
152
174
 
153
175
  Faster (batched tail) list gathering for methods without `filter` parameter (e.g. `department.get`).
154
176
  """
177
+ request = Request.model_validate(request)
155
178
  list_size = list_size or self.settings.list_size
156
179
  batch_size = batch_size or self.settings.batch_size
157
180
 
158
- head_response = self._call(head_request)
159
- yield from self._normalize_list(head_response.result)
181
+ head_request = request.model_copy(deep=True)
182
+ head_request.parameters["start"] = 0
183
+
184
+ head_response = self._call_retry(head_request)
185
+ yield from self._fix_list_result(head_response.result)
160
186
 
161
187
  if head_response.next and head_response.next != list_size:
162
188
  raise ValueError(f"Expecting chunk size to be {list_size}. Got: {head_response.next}")
@@ -165,18 +191,17 @@ class Bitrix24:
165
191
  total = head_response.total or 0
166
192
  for start in range(list_size, total, list_size):
167
193
  tail_request = head_request.model_copy(deep=True)
168
- tail_request.parameters |= {"start": start}
194
+ tail_request.parameters["start"] = start
169
195
  yield tail_request
170
196
 
171
197
  tail_responses = self.batch(_tail_requests(), batch_size)
172
- tail_responses = map(self._normalize_list, tail_responses)
198
+ tail_responses = map(self._fix_list_result, tail_responses)
173
199
  tail_responses = chain.from_iterable(tail_responses)
174
-
175
200
  yield from tail_responses
176
201
 
177
202
  def list_batched_no_count(
178
203
  self,
179
- request: ListRequest,
204
+ request: ListRequest | dict,
180
205
  id_key: str = "ID",
181
206
  list_size: int | None = None,
182
207
  batch_size: int | None = None,
@@ -185,6 +210,7 @@ class Bitrix24:
185
210
 
186
211
  Fastest (batched, no count) list gathering for methods with `filter` parameter (e.g. `crm.lead.list`).
187
212
  """
213
+ request = ListRequest.model_validate(request)
188
214
  list_size = list_size or self.settings.list_size
189
215
  batch_size = batch_size or self.settings.batch_size
190
216
 
@@ -192,7 +218,8 @@ class Bitrix24:
192
218
  if "*" not in select_ and id_key not in select_:
193
219
  request.select.append(id_key)
194
220
 
195
- id_from, id_to = f">={id_key}", f"<{id_key}"
221
+ id_from, id_to = f">{id_key}", f"<{id_key}"
222
+ get_id = itemgetter(id_key)
196
223
 
197
224
  filter_ = request.parameters.filter
198
225
  if filter_ and (id_from in filter_ or id_to in filter_):
@@ -211,39 +238,94 @@ class Bitrix24:
211
238
  tail_request.parameters.start = -1
212
239
  tail_request.parameters.order = {"ID": "DESC"}
213
240
 
214
- head_tail = self.batch([head_request, tail_request])
215
- head, tail = tuple(map(self._normalize_list, head_tail))
216
-
217
- get_id = itemgetter(id_key)
218
- max_head = max(map(int, map(get_id, head)), default=None)
219
- min_tail = min(map(int, map(get_id, tail)), default=None)
220
-
221
- yield from head
241
+ head_tail_result = self.batch([head_request, tail_request])
242
+ head_result, tail_result = tuple(map(self._fix_list_result, head_tail_result))
243
+ yield from head_result
222
244
 
223
- if max_head < min_tail:
245
+ max_head_id = max(map(int, map(get_id, head_result)), default=None)
246
+ min_tail_id = min(map(int, map(get_id, tail_result)), default=None)
224
247
 
225
- def _body_requests() -> Generator[Request, None, None]:
226
- for start in range(max_head + 1, min_tail, list_size):
227
- body_request = request.model_copy(deep=True)
228
- body_request.parameters.start = -1
229
- body_request.parameters.filter[id_from] = start
230
- body_request.parameters.filter[id_to] = min(start + list_size, min_tail)
231
- body_request.parameters.order = {"ID": "ASC"}
232
- yield body_request
248
+ def _body_requests() -> Generator[ListRequest, None, None]:
249
+ for start in range(max_head_id, min_tail_id, list_size):
250
+ body_request = head_request.model_copy(deep=True)
251
+ body_request.parameters.filter[id_from] = start
252
+ body_request.parameters.filter[id_to] = min(start + list_size + 1, min_tail_id)
253
+ yield body_request
233
254
 
255
+ if max_head_id and min_tail_id and max_head_id < min_tail_id:
234
256
  body = self.batch(_body_requests(), batch_size)
235
- body = map(self._normalize_list, body)
257
+ body = map(self._fix_list_result, body)
236
258
  body = chain.from_iterable(body)
237
-
238
259
  yield from body
239
260
 
240
- for item in reversed(tail):
241
- if int(get_id(item)) > max_head:
261
+ for item in reversed(tail_result):
262
+ if int(get_id(item)) > max_head_id:
242
263
  yield item
243
264
 
265
+ def reference_batched_no_count(
266
+ self,
267
+ request: ListRequest | dict,
268
+ updates: Iterable[dict],
269
+ id_key: str = "ID",
270
+ list_size: int | None = None,
271
+ batch_size: int | None = None,
272
+ ) -> Generator[ApiTypes, None, None]:
273
+ """Call `list` method with reference `updates` to filter and return full `result`.
274
+
275
+ Fastest (batched, no count) list gathering for methods with `filter` parameter and required `reference`
276
+ (e.g. `crm.timeline.comment.list`).
277
+ """
278
+ request = ListRequest.model_validate(request)
279
+ list_size = list_size or self.settings.list_size
280
+ batch_size = batch_size or self.settings.batch_size
281
+
282
+ select_ = request.parameters.select
283
+ if "*" not in select_ and id_key not in select_:
284
+ request.select.append(id_key)
285
+
286
+ id_from = f">{id_key}"
287
+ get_id = itemgetter(id_key)
288
+
289
+ filter_ = request.parameters.filter
290
+ if filter_ and id_from in filter_:
291
+ raise ValueError(
292
+ f"Filter parameters `{id_from}` is reserved in `reference_batched_no_count`",
293
+ )
294
+
295
+ if request.parameters.order:
296
+ raise ValueError("Ordering parameters are reserved `order`in `reference_batched_no_count`")
297
+
298
+ def _tail_requests() -> Generator[ListRequest, None, None]:
299
+ for update in updates:
300
+ if id_from in update:
301
+ raise ValueError(
302
+ f"Filter parameters `{id_from}` is reserved in `reference_batched_no_count`",
303
+ )
304
+ tail_request = request.model_copy(deep=True)
305
+ tail_request.parameters.filter |= update
306
+ tail_request.parameters.start = -1
307
+ tail_request.parameters.order = {"ID": "ASC"}
308
+ yield tail_request
309
+
310
+ head_requests = []
311
+ tail_requests = iter(_tail_requests())
312
+ while body_requests := head_requests + list(islice(tail_requests, batch_size - len(head_requests))):
313
+ body_results = self.batch(body_requests, batch_size)
314
+ body_results = map(self._fix_list_result, body_results)
315
+
316
+ head_requests = []
317
+ for body_request, body_result in zip(body_requests, body_results, strict=True):
318
+ if len(body_result) == list_size:
319
+ max_id = max(map(int, map(get_id, body_result)), default=None)
320
+ head_request = body_request.model_copy(deep=True)
321
+ head_request.parameters.filter[id_from] = max_id
322
+ head_requests.append(head_request)
323
+
324
+ yield from body_result
325
+
244
326
  @staticmethod
245
- def _normalize_list(result: list | dict[str, list]) -> list:
246
- """Normalize `list` method result to `list of items` structure.
327
+ def _fix_list_result(result: list | dict[str, list]) -> list:
328
+ """Fix `list` method result to `list of items` structure.
247
329
 
248
330
  There are two kinds of what `list` method `result` may contain:
249
331
  - a list of items (e.g. `department-get` and `disk.folder.getchildren`),
@@ -1,7 +1,7 @@
1
1
  from datetime import datetime
2
- from typing import Self
2
+ from typing import Annotated, Any, Self
3
3
 
4
- from pydantic import BaseModel
4
+ from pydantic import BaseModel, BeforeValidator
5
5
 
6
6
  from b24api.error import ApiResponseError, RetryApiResponseError
7
7
  from b24api.query import build_query
@@ -9,7 +9,7 @@ from b24api.type import ApiTypes
9
9
 
10
10
 
11
11
  class Request(BaseModel):
12
- """Common request structure."""
12
+ """API request."""
13
13
 
14
14
  method: str
15
15
  parameters: dict[str, ApiTypes] = {}
@@ -30,24 +30,23 @@ class Request(BaseModel):
30
30
  class ListRequestParameters(BaseModel):
31
31
  """Parameters of `*.list` requests."""
32
32
 
33
- select: list[str]
34
- filter: dict[str, ApiTypes] | None = None
35
- order: dict[str, str] | None = None
36
- limit: int | None = None
33
+ select: list[str] = []
34
+ filter: dict[str, ApiTypes] = {}
35
+ order: dict[str, str] = {}
37
36
  start: int | None = None
38
37
 
39
38
 
40
39
  class ListRequest(Request):
41
- """List request structure."""
40
+ """API `*.list` request."""
42
41
 
43
- parameters: ListRequestParameters = None
42
+ parameters: ListRequestParameters
44
43
 
45
44
 
46
45
  class ErrorResponse(BaseModel):
47
46
  """API error response."""
48
47
 
49
48
  error: str
50
- error_description: str | None = None
49
+ error_description: str
51
50
 
52
51
  def raise_error(self, retry_errors: list[str]) -> Self:
53
52
  if self.error in retry_errors:
@@ -62,7 +61,7 @@ class ErrorResponse(BaseModel):
62
61
 
63
62
 
64
63
  class ResponseTime(BaseModel):
65
- """Time structure of response."""
64
+ """API Response `time` structure."""
66
65
 
67
66
  start: float
68
67
  finish: float
@@ -70,12 +69,12 @@ class ResponseTime(BaseModel):
70
69
  processing: float
71
70
  date_start: datetime
72
71
  date_finish: datetime
73
- operating_reset_at: float
74
- operating: float
72
+ operating_reset_at: float | None = None
73
+ operating: float | None = None
75
74
 
76
75
 
77
- class ResultResponse(BaseModel):
78
- """API data response."""
76
+ class Response(BaseModel):
77
+ """API response."""
79
78
 
80
79
  result: ApiTypes
81
80
  time: ResponseTime
@@ -83,9 +82,17 @@ class ResultResponse(BaseModel):
83
82
  next: int | None = None
84
83
 
85
84
 
85
+ def _php_dict(value: Any) -> Any: # noqa: ANN401
86
+ if isinstance(value, list) and not value:
87
+ return {}
88
+ return value
89
+
90
+
86
91
  class BatchResult(BaseModel):
87
- result: dict[str, ApiTypes]
88
- result_time: dict[str, ResponseTime]
89
- result_error: dict[str, ErrorResponse]
90
- result_total: dict[str, int]
91
- result_next: dict[str, int]
92
+ """API response `result` structure for `batch` method."""
93
+
94
+ result: Annotated[dict[str, ApiTypes], BeforeValidator(_php_dict)]
95
+ result_time: Annotated[dict[str, ResponseTime], BeforeValidator(_php_dict)]
96
+ result_error: Annotated[dict[str, ErrorResponse], BeforeValidator(_php_dict)]
97
+ result_total: Annotated[dict[str, int], BeforeValidator(_php_dict)]
98
+ result_next: Annotated[dict[str, int], BeforeValidator(_php_dict)]
@@ -14,7 +14,12 @@ class ApiResponseError(Exception):
14
14
  code: str,
15
15
  description: str | None,
16
16
  ) -> None:
17
- message = f"API error [{code}]: {description}"
17
+ if code and description:
18
+ message = f"API error [{code}]: {description}"
19
+ elif code:
20
+ message = f"API error [{code}]"
21
+ else:
22
+ message = f"API error: {description}"
18
23
  super().__init__(message)
19
24
 
20
25
 
@@ -1,3 +1,4 @@
1
+ import logging
1
2
  from collections.abc import Generator
2
3
  from typing import Annotated
3
4
 
@@ -6,7 +7,11 @@ from httpx import Client
6
7
 
7
8
 
8
9
  def httpx_client() -> Generator[Client, None, None]:
9
- client = Client(http2=True)
10
+ httpx_logger = logging.getLogger("httpx")
11
+ httpx_logger.setLevel(logging.WARNING)
12
+
13
+ client = Client(http2=True, timeout=30)
14
+
10
15
  yield client
11
16
 
12
17
 
@@ -0,0 +1,36 @@
1
+ from datetime import datetime
2
+ from urllib.parse import quote_plus
3
+
4
+ from b24api.type import ApiTypes
5
+
6
+
7
+ def build_query(parameters: dict[int | str, ApiTypes], path: str = "%s") -> str:
8
+ query = []
9
+
10
+ if parameters is None:
11
+ return ""
12
+
13
+ for key, value in parameters.items():
14
+ if value is None:
15
+ continue
16
+
17
+ value_ = value
18
+
19
+ if isinstance(value_, list | tuple):
20
+ value_ = dict(enumerate(value))
21
+
22
+ if isinstance(value_, dict):
23
+ subquery = build_query(value_, path % key + "[%s]")
24
+ else:
25
+ key_ = quote_plus(path % key)
26
+
27
+ if isinstance(value_, datetime):
28
+ value_ = value.astimezone().isoformat()
29
+ value_ = quote_plus(str(value_))
30
+
31
+ subquery = f"{key_}={value_}"
32
+
33
+ if subquery:
34
+ query.append(subquery)
35
+
36
+ return "&".join(query)
@@ -9,7 +9,7 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
9
9
 
10
10
  class Settings(BaseSettings):
11
11
  model_config = SettingsConfigDict(
12
- env_prefix="bitrix_24_api_",
12
+ env_prefix="bitrix24_api_",
13
13
  env_file=".env",
14
14
  extra="ignore",
15
15
  )
@@ -35,8 +35,8 @@ class Settings(BaseSettings):
35
35
  batch_size: int = 50
36
36
 
37
37
 
38
- def api_settings() -> Generator[Settings, None, None]:
39
- settings = Settings()
38
+ def api_settings(**kwargs: dict) -> Generator[Settings, None, None]:
39
+ settings = Settings(**kwargs)
40
40
  yield settings
41
41
 
42
42
 
@@ -0,0 +1,93 @@
1
+ Metadata-Version: 2.4
2
+ Name: b24api
3
+ Version: 0.3.0
4
+ Summary: Bitrix24 API
5
+ Requires-Python: >=3.11
6
+ Description-Content-Type: text/markdown
7
+ License-File: LICENSE
8
+ Requires-Dist: fast-depends>=2.4.12
9
+ Requires-Dist: httpx[http2]>=0.28.1
10
+ Requires-Dist: pydantic>=2.10.6
11
+ Requires-Dist: pydantic-settings>=2.8.1
12
+ Requires-Dist: retry>=0.9.2
13
+ Dynamic: license-file
14
+
15
+ # API client for Bitrix24
16
+
17
+ Low-level API client with multiple strategies for lists gathering.
18
+ All methods support retries.
19
+
20
+ ## Regular call (any method)
21
+ ```python
22
+ from b24api import Bitrix24
23
+
24
+ b24 = Bitrix24()
25
+ result = b24.call({"method": "user.access", "parameters": {"ACCESS": ["G2", "AU"]}})
26
+ print(result)
27
+ ```
28
+
29
+ ## Batch call (any method, no limit)
30
+ ```python
31
+ from b24api import Bitrix24
32
+
33
+ b24 = Bitrix24()
34
+
35
+ requests = ({"method": "user.update", "parameters": {"ID": u, "UF_SKYPE": ""}} for u in range(1000))
36
+ result = b24.batch(requests)
37
+ print(result)
38
+ ```
39
+
40
+ ## Gathering full list (slow)
41
+ Applicable to list methods with `start=<offset>` support.
42
+ Fetches list chunks one by one.
43
+
44
+ ```python
45
+ from b24api import Bitrix24
46
+
47
+ b24 = Bitrix24()
48
+ result = b24.list_sequential({"method": "user.get"})
49
+ print(list(result))
50
+ ```
51
+
52
+ ## Gathering full list (faster)
53
+ Applicable to list methods with `start=<offset>` support.
54
+ Fetches first list chunk with regular call, then fetches other chunks with `batch`.
55
+ Approximately 3 times faster then `list_sequential`.
56
+
57
+ ```python
58
+ from b24api import Bitrix24
59
+
60
+ b24 = Bitrix24()
61
+ result = b24.list_batched({"method": "user.get"})
62
+ print(list(result))
63
+ ```
64
+
65
+ ## Gathering full list (fastest)
66
+ Applicable to list methods with `filter={<parameters>}` support.
67
+ Fetches first and last list chunk with batch call, then fetches other chunks with `batch`. Doesn't use counting (`start=-1`).
68
+ Approximately 2 times faster then `list_batched`.
69
+
70
+ ```python
71
+ from b24api import Bitrix24
72
+
73
+ b24 = Bitrix24()
74
+ result = b24.list_batched_no_count({"method": "user.get"})
75
+ print(list(result))
76
+ ```
77
+
78
+ ## Gathering full list with required reference
79
+ Applicable to list methods with `select=[<fields>]` and `filter={<parameters>}` support and required filter parameters.
80
+ Fetches first and last list chunk with batch call, then fetches other chunks with `batch`. Doesn't use counting (`start=-1`).
81
+
82
+ ```python
83
+ from b24api import Bitrix24
84
+
85
+ b24 = Bitrix24()
86
+ deal_ids = [1, 2, 3] # deals IDs (e.g. from "crm.deal.list" call)
87
+ filter_updates = ({"=ENTITY_ID": i} for i in deal_ids)
88
+ result = b24.reference_batched_no_count(
89
+ {"method": "crm.timeline.comment.list", "parameters": {"ENTITY_TYPE": "deal"}},
90
+ filter_updates,
91
+ )
92
+ print(list(result))
93
+ ```
@@ -6,8 +6,7 @@ b24api/__init__.py
6
6
  b24api/api.py
7
7
  b24api/entity.py
8
8
  b24api/error.py
9
- b24api/future.py
10
- b24api/http.py
9
+ b24api/httpc.py
11
10
  b24api/query.py
12
11
  b24api/settings.py
13
12
  b24api/type.py
@@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta"
5
5
 
6
6
  [project]
7
7
  name = "b24api"
8
- version = "0.1.3"
8
+ version = "0.3.0"
9
9
  description = "Bitrix24 API"
10
10
  readme = "README.md"
11
11
  requires-python = ">=3.11"
@@ -19,11 +19,9 @@ dependencies = [
19
19
 
20
20
  [dependency-groups]
21
21
  dev = [
22
- "mypy>=1.15.0",
23
22
  "pytest-env>=1.1.5",
24
23
  "pytest-httpx>=0.35.0",
25
24
  "pytest-mock>=3.14.0",
26
- "ruff>=0.9.9",
27
25
  ]
28
26
 
29
27
  [tool.ruff]
@@ -59,7 +57,7 @@ addopts = ["--strict-config", "--strict-markers"]
59
57
  xfail_strict = true
60
58
 
61
59
  [tool.pytest_env]
62
- BITRIX_24_API_WEBHOOK_URL = "https://bitrix24.com/rest/0/test/"
60
+ BITRIX24_API_WEBHOOK_URL = "https://bitrix24.com/rest/0/test/"
63
61
 
64
62
 
65
63
  [tool.coverage.report]
b24api-0.1.3/PKG-INFO DELETED
@@ -1,16 +0,0 @@
1
- Metadata-Version: 2.2
2
- Name: b24api
3
- Version: 0.1.3
4
- Summary: Bitrix24 API
5
- Requires-Python: >=3.11
6
- Description-Content-Type: text/markdown
7
- License-File: LICENSE
8
- Requires-Dist: fast-depends>=2.4.12
9
- Requires-Dist: httpx[http2]>=0.28.1
10
- Requires-Dist: pydantic>=2.10.6
11
- Requires-Dist: pydantic-settings>=2.8.1
12
- Requires-Dist: retry>=0.9.2
13
-
14
- # API client for Bitrix24
15
-
16
- Low-level API client with multiple strategies for lists gathering.
b24api-0.1.3/README.md DELETED
@@ -1,3 +0,0 @@
1
- # API client for Bitrix24
2
-
3
- Low-level API client with multiple strategies for lists gathering.
@@ -1,12 +0,0 @@
1
- try:
2
- from itertools import batched # python 3.12+
3
- except ImportError:
4
- from collections.abc import Generator, Iterable
5
- from itertools import islice
6
-
7
- def batched(iterable: Iterable, n: int) -> Generator[tuple, None, None]:
8
- if n < 1:
9
- raise ValueError("n must be at least one")
10
- iterator = iter(iterable)
11
- while batch := tuple(islice(iterator, n)):
12
- yield batch
@@ -1,31 +0,0 @@
1
- from datetime import datetime
2
- from urllib.parse import quote_plus
3
-
4
- from b24api.type import ApiTypes
5
-
6
-
7
- def build_query(parameters: dict[int | str, ApiTypes], convention: str = "%s") -> str:
8
- query = []
9
-
10
- if parameters is None:
11
- return ""
12
-
13
- for key, value in parameters.items():
14
- if value is None:
15
- continue
16
-
17
- if isinstance(value, list | tuple):
18
- value = dict(enumerate(value)) # noqa: PLW2901
19
-
20
- if isinstance(value, dict):
21
- subquery = build_query(value, convention % key + "[%s]")
22
- else:
23
- key_ = quote_plus(convention % key)
24
- value_ = value.isoformat() if isinstance(value, datetime) else value
25
- # TODO: check date filtering [with .replace(microsecond=0).astimezone()]
26
- value_ = quote_plus(str(value_))
27
- subquery = f"{key_}={value_}"
28
-
29
- query.append(subquery)
30
-
31
- return "&".join(query)
@@ -1,16 +0,0 @@
1
- Metadata-Version: 2.2
2
- Name: b24api
3
- Version: 0.1.3
4
- Summary: Bitrix24 API
5
- Requires-Python: >=3.11
6
- Description-Content-Type: text/markdown
7
- License-File: LICENSE
8
- Requires-Dist: fast-depends>=2.4.12
9
- Requires-Dist: httpx[http2]>=0.28.1
10
- Requires-Dist: pydantic>=2.10.6
11
- Requires-Dist: pydantic-settings>=2.8.1
12
- Requires-Dist: retry>=0.9.2
13
-
14
- # API client for Bitrix24
15
-
16
- Low-level API client with multiple strategies for lists gathering.
File without changes
File without changes
File without changes
File without changes
File without changes