b24api 0.1.2__py3-none-any.whl

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/__init__.py ADDED
@@ -0,0 +1 @@
1
+ from b24api.api import Bitrix24
b24api/api.py ADDED
@@ -0,0 +1,273 @@
1
+ import contextlib
2
+ from collections.abc import Generator, Iterable
3
+ from itertools import chain
4
+ from operator import itemgetter
5
+
6
+ import httpx
7
+ from fast_depends import inject
8
+ from pydantic import ValidationError
9
+ from retry.api import retry_call
10
+
11
+ from b24api.entity import ApiTypes, BatchResult, ErrorResponse, ListRequest, Request, ResultResponse
12
+ from b24api.error import RetryApiResponseError, RetryHTTPStatusError
13
+ from b24api.future import batched
14
+ from b24api.http import HttpxClient
15
+ from b24api.settings import ApiSettings
16
+
17
+
18
+ class Bitrix24:
19
+ @inject
20
+ def __init__(self, client: HttpxClient, settings: ApiSettings) -> None:
21
+ self.client = client
22
+ 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],
29
+ exceptions=(RetryHTTPStatusError, RetryApiResponseError),
30
+ tries=self.settings.retry_tries,
31
+ delay=self.settings.retry_delay,
32
+ backoff=self.settings.retry_backoff,
33
+ )
34
+
35
+ return response.result
36
+
37
+ def _call(self, request: Request) -> ResultResponse:
38
+ """Call any method and return full response."""
39
+ http_response = self.client.post(
40
+ f"{self.settings.webhook_url}{request.method}",
41
+ headers={"Content-Type": "application/json"},
42
+ json=request.model_dump()["parameters"],
43
+ )
44
+
45
+ try:
46
+ json_response = http_response.raise_for_status().json()
47
+ except httpx.HTTPStatusError as error:
48
+ if http_response.status_code in self.settings.retry_statuses:
49
+ raise RetryHTTPStatusError(
50
+ str(error),
51
+ request=error.request,
52
+ response=error.response,
53
+ ) from error
54
+ raise
55
+
56
+ with contextlib.suppress(ValidationError):
57
+ ErrorResponse.model_validate(json_response).raise_error(self.settings.retry_errors)
58
+
59
+ return ResultResponse.model_validate(json_response)
60
+
61
+ def batch(
62
+ self,
63
+ requests: Iterable[Request],
64
+ batch_size: int | None = None,
65
+ ) -> Generator[ApiTypes, None, None]:
66
+ """Call infinite sequence of methods within batches and return just `result`s."""
67
+ batch_size = batch_size or self.settings.batch_size
68
+
69
+ for batched_requests in batched(requests, batch_size):
70
+ for response in self._batch(batched_requests):
71
+ yield response.result
72
+
73
+ def _batch(self, requests: Iterable[Request]) -> Generator[ResultResponse, None, None]:
74
+ """Call batch of methods and return full responses."""
75
+ commands = {f"_{i}": request for i, request in enumerate(requests)}
76
+ request = Request(
77
+ method="batch",
78
+ parameters={
79
+ "halt": True,
80
+ "cmd": {key: request.query for key, request in commands.items()},
81
+ },
82
+ )
83
+
84
+ result = self.call(request)
85
+
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
+ result = BatchResult.model_validate(result)
91
+
92
+ for i in range(len(commands)):
93
+ key = f"_{i}"
94
+
95
+ if key in result.result_error:
96
+ ErrorResponse.model_validate(result.result_error[key]).raise_error(self.settings.retry_errors)
97
+
98
+ command = commands[key]
99
+ if key not in result.result:
100
+ raise ValueError(
101
+ f"Expecting `result` to contain result for command {{`{key}`: {command}}}. Got: {result}",
102
+ )
103
+ if key not in result.result_time:
104
+ raise ValueError(
105
+ f"Expecting `result_time` to contain result for command {{`{key}`: {command}}}. Got: {result}",
106
+ )
107
+
108
+ yield ResultResponse(
109
+ result=result.result[key],
110
+ time=result.result_time[key],
111
+ total=result.result_total.get(key, None),
112
+ next=result.result_next.get(key, None),
113
+ )
114
+
115
+ def list_sequential(
116
+ self,
117
+ head_request: Request,
118
+ list_size: int | None = None,
119
+ ) -> Generator[ApiTypes, None, None]:
120
+ """Call `list` method and return full `result`.
121
+
122
+ Slow (sequential tail) list gathering for methods without `filter` parameter (e.g. `department.get`).
123
+ """
124
+ list_size = list_size or self.settings.list_size
125
+
126
+ head_response = self._call(head_request)
127
+ yield from self._normalize_list(head_response.result)
128
+
129
+ if head_response.next and head_response.next != list_size:
130
+ raise ValueError(f"Expecting list chunk size to be {list_size}. Got: {head_response.next}")
131
+
132
+ total = head_response.total or 0
133
+ for start in range(list_size, total, list_size):
134
+ tail_request = head_request.model_copy(deep=True)
135
+ tail_request.parameters |= {"start": start}
136
+ tail_response = self._call(tail_request)
137
+
138
+ if tail_response.next and tail_response.next != start + list_size:
139
+ raise ValueError(
140
+ f"Expecting next list chunk to start at {start + list_size}. Got: {tail_response.next}",
141
+ )
142
+
143
+ yield from self._normalize_list(tail_response.result)
144
+
145
+ def list_batched(
146
+ self,
147
+ head_request: Request,
148
+ list_size: int | None = None,
149
+ batch_size: int | None = None,
150
+ ) -> Generator[ApiTypes, None, None]:
151
+ """Call `list` method and return full `result`.
152
+
153
+ Faster (batched tail) list gathering for methods without `filter` parameter (e.g. `department.get`).
154
+ """
155
+ list_size = list_size or self.settings.list_size
156
+ batch_size = batch_size or self.settings.batch_size
157
+
158
+ head_response = self._call(head_request)
159
+ yield from self._normalize_list(head_response.result)
160
+
161
+ if head_response.next and head_response.next != list_size:
162
+ raise ValueError(f"Expecting chunk size to be {list_size}. Got: {head_response.next}")
163
+
164
+ def _tail_requests() -> Generator[Request, None, None]:
165
+ total = head_response.total or 0
166
+ for start in range(list_size, total, list_size):
167
+ tail_request = head_request.model_copy(deep=True)
168
+ tail_request.parameters |= {"start": start}
169
+ yield tail_request
170
+
171
+ tail_responses = self.batch(_tail_requests(), batch_size)
172
+ tail_responses = map(self._normalize_list, tail_responses)
173
+ tail_responses = chain.from_iterable(tail_responses)
174
+
175
+ yield from tail_responses
176
+
177
+ def list_batched_no_count(
178
+ self,
179
+ request: ListRequest,
180
+ id_key: str = "ID",
181
+ list_size: int | None = None,
182
+ batch_size: int | None = None,
183
+ ) -> Generator[ApiTypes, None, None]:
184
+ """Call `list` method and return full `result`.
185
+
186
+ Fastest (batched, no count) list gathering for methods with `filter` parameter (e.g. `crm.lead.list`).
187
+ """
188
+ list_size = list_size or self.settings.list_size
189
+ batch_size = batch_size or self.settings.batch_size
190
+
191
+ select_ = request.parameters.select
192
+ if "*" not in select_ and id_key not in select_:
193
+ request.select.append(id_key)
194
+
195
+ id_from, id_to = f">={id_key}", f"<{id_key}"
196
+
197
+ filter_ = request.parameters.filter
198
+ if filter_ and (id_from in filter_ or id_to in filter_):
199
+ raise ValueError(
200
+ f"Filter parameters `{id_from}` and `{id_to}` are reserved in `list_batched_no_count`",
201
+ )
202
+
203
+ if request.parameters.order:
204
+ raise ValueError("Ordering parameters are reserved `order`in `list_batched_no_count`")
205
+
206
+ head_request = request.model_copy(deep=True)
207
+ head_request.parameters.start = -1
208
+ head_request.parameters.order = {"ID": "ASC"}
209
+
210
+ tail_request = request.model_copy(deep=True)
211
+ tail_request.parameters.start = -1
212
+ tail_request.parameters.order = {"ID": "DESC"}
213
+
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
222
+
223
+ if max_head < min_tail:
224
+
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
233
+
234
+ body = self.batch(_body_requests(), batch_size)
235
+ body = map(self._normalize_list, body)
236
+ body = chain.from_iterable(body)
237
+
238
+ yield from body
239
+
240
+ for item in reversed(tail):
241
+ if int(get_id(item)) > max_head:
242
+ yield item
243
+
244
+ @staticmethod
245
+ def _normalize_list(result: list | dict[str, list]) -> list:
246
+ """Normalize `list` method result to `list of items` structure.
247
+
248
+ There are two kinds of what `list` method `result` may contain:
249
+ - a list of items (e.g. `department-get` and `disk.folder.getchildren`),
250
+ - a dictionary with single item that contains the desired list of items
251
+ (e.g. `tasks` in `tasks.task.list`).
252
+ """
253
+ if not isinstance(result, list | dict):
254
+ raise TypeError(f"Expecting `result` to be a `list` or a `dict`. Got: {result}")
255
+
256
+ if not result:
257
+ return []
258
+
259
+ if isinstance(result, list):
260
+ return result
261
+
262
+ if len(result) != 1:
263
+ raise TypeError(
264
+ f"If `result` is a `dict`, expecting single item. Got: {result}",
265
+ )
266
+
267
+ key = next(iter(result))
268
+ value = result[key]
269
+
270
+ if not isinstance(value, list):
271
+ raise TypeError(f"If `result` is a `dict`, expecting single `list` item. Got: {result}")
272
+
273
+ return value
b24api/entity.py ADDED
@@ -0,0 +1,91 @@
1
+ from datetime import datetime
2
+ from typing import Self
3
+
4
+ from pydantic import BaseModel
5
+
6
+ from b24api.error import ApiResponseError, RetryApiResponseError
7
+ from b24api.query import build_query
8
+ from b24api.type import ApiTypes
9
+
10
+
11
+ class Request(BaseModel):
12
+ """Common request structure."""
13
+
14
+ method: str
15
+ parameters: dict[str, ApiTypes] = {}
16
+
17
+ @property
18
+ def query(self) -> str:
19
+ if not self.parameters:
20
+ return self.method
21
+
22
+ parameters = self.parameters
23
+ if isinstance(self.parameters, BaseModel):
24
+ parameters = self.parameters.model_dump()
25
+ query = build_query(parameters)
26
+
27
+ return f"{self.method}?{query}"
28
+
29
+
30
+ class ListRequestParameters(BaseModel):
31
+ """Parameters of `*.list` requests."""
32
+
33
+ select: list[str]
34
+ filter: dict[str, ApiTypes] | None = None
35
+ order: dict[str, str] | None = None
36
+ limit: int | None = None
37
+ start: int | None = None
38
+
39
+
40
+ class ListRequest(Request):
41
+ """List request structure."""
42
+
43
+ parameters: ListRequestParameters = None
44
+
45
+
46
+ class ErrorResponse(BaseModel):
47
+ """API error response."""
48
+
49
+ error: str
50
+ error_description: str | None = None
51
+
52
+ def raise_error(self, retry_errors: list[str]) -> Self:
53
+ if self.error in retry_errors:
54
+ raise RetryApiResponseError(
55
+ code=self.error,
56
+ description=self.error_description,
57
+ )
58
+ raise ApiResponseError(
59
+ code=self.error,
60
+ description=self.error_description,
61
+ )
62
+
63
+
64
+ class ResponseTime(BaseModel):
65
+ """Time structure of response."""
66
+
67
+ start: float
68
+ finish: float
69
+ duration: float
70
+ processing: float
71
+ date_start: datetime
72
+ date_finish: datetime
73
+ operating_reset_at: float
74
+ operating: float
75
+
76
+
77
+ class ResultResponse(BaseModel):
78
+ """API data response."""
79
+
80
+ result: ApiTypes
81
+ time: ResponseTime
82
+ total: int | None = None
83
+ next: int | None = None
84
+
85
+
86
+ 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]
b24api/error.py ADDED
@@ -0,0 +1,22 @@
1
+ import httpx
2
+
3
+
4
+ class RetryHTTPStatusError(httpx.HTTPStatusError):
5
+ """HTTP error that may be retried."""
6
+
7
+
8
+ class ApiResponseError(Exception):
9
+ """API error with description."""
10
+
11
+ def __init__(
12
+ self,
13
+ *,
14
+ code: str,
15
+ description: str | None,
16
+ ) -> None:
17
+ message = f"API error [{code}]: {description}"
18
+ super().__init__(message)
19
+
20
+
21
+ class RetryApiResponseError(ApiResponseError):
22
+ """API error that may be retried."""
b24api/future.py ADDED
@@ -0,0 +1,12 @@
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
b24api/http.py ADDED
@@ -0,0 +1,13 @@
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)
10
+ yield client
11
+
12
+
13
+ HttpxClient = Annotated[Client, Depends(httpx_client)]
b24api/query.py ADDED
@@ -0,0 +1,31 @@
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)
b24api/settings.py ADDED
@@ -0,0 +1,43 @@
1
+ from collections.abc import Generator
2
+ from typing import Annotated
3
+
4
+ from fast_depends import Depends
5
+ from httpx import codes
6
+ from pydantic import HttpUrl
7
+ from pydantic_settings import BaseSettings, SettingsConfigDict
8
+
9
+
10
+ class Settings(BaseSettings):
11
+ model_config = SettingsConfigDict(
12
+ env_prefix="bitrix_24_api_",
13
+ env_file=".env",
14
+ extra="ignore",
15
+ )
16
+
17
+ webhook_url: HttpUrl
18
+
19
+ retry_statuses: list[int] = (
20
+ codes.LOCKED,
21
+ codes.TOO_EARLY,
22
+ codes.TOO_MANY_REQUESTS,
23
+ codes.INTERNAL_SERVER_ERROR,
24
+ codes.BAD_GATEWAY,
25
+ codes.SERVICE_UNAVAILABLE,
26
+ codes.INSUFFICIENT_STORAGE,
27
+ )
28
+ retry_errors: list[str] = ["query_limit_exceeded", "operation_time_limit"]
29
+
30
+ retry_tries: int = 5
31
+ retry_delay: float = 5
32
+ retry_backoff: float = 2
33
+
34
+ list_size: int = 50
35
+ batch_size: int = 50
36
+
37
+
38
+ def api_settings() -> Generator[Settings, None, None]:
39
+ settings = Settings()
40
+ yield settings
41
+
42
+
43
+ ApiSettings = Annotated[Settings, Depends(api_settings)]
b24api/type.py ADDED
@@ -0,0 +1,5 @@
1
+ from datetime import datetime
2
+ from typing import TypeAlias
3
+
4
+ # Types allowed in response and request
5
+ ApiTypes: TypeAlias = bool | str | int | float | dict | list | datetime | None
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Shkarupa Alex
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,16 @@
1
+ Metadata-Version: 2.2
2
+ Name: b24api
3
+ Version: 0.1.2
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.
@@ -0,0 +1,14 @@
1
+ b24api/__init__.py,sha256=EITmZQavNFXCJPFd53sF3imOBajCJnV9bjDrnL-oQdU,31
2
+ b24api/api.py,sha256=QZ1p0vzGf8AFNnbd0irxo2FoBtvX-pTPd2Sb9hQLGJQ,10483
3
+ b24api/entity.py,sha256=3G1EQgxOsmmlXzwVvlNENBKVX-R8nHWb_-gpfyFva6s,2146
4
+ b24api/error.py,sha256=1-3uLKYxYnJyg140vAl-861_SMWr1fh1jbJYN3-HyKY,473
5
+ b24api/future.py,sha256=BJdIfHXDX1IEJCk9X9mcDXGiLXIs-SK5G2RNDMXTL2I,420
6
+ b24api/http.py,sha256=kAhbbgw3LeQqQ7vYjL8WicHBejPDJJzzmVOhb5aTAY4,287
7
+ b24api/query.py,sha256=0M_zjfapNyCdeEOQsy9iA1kUgX2BxH0egnq8dV9kjoQ,926
8
+ b24api/settings.py,sha256=v7IlEXAYmLkVM1zRAw8mp5raNQ88s5HM3AlzuxhkY9Q,1034
9
+ b24api/type.py,sha256=fRhONZS9DCLjuHF6KkqsEtY7tLvzN-vtgY9TnghCw3c,179
10
+ b24api-0.1.2.dist-info/LICENSE,sha256=ssnbHJpzi6Jw6vLkOfnUnF9fLjKIRWGRD3zWBCWYj9k,1070
11
+ b24api-0.1.2.dist-info/METADATA,sha256=OkNLTgf24-DBqFvFO55QCZXWhpS-n38emxs2Pya_WL8,426
12
+ b24api-0.1.2.dist-info/WHEEL,sha256=52BFRY2Up02UkjOa29eZOS2VxUrpPORXg1pkohGGUS8,91
13
+ b24api-0.1.2.dist-info/top_level.txt,sha256=mN7BLg3tL8GQgfBzSoJhjGgXqwKaflyMHCt3XDWIWiI,7
14
+ b24api-0.1.2.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (76.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ b24api