Repository: leshchenko1979/fast_bitrix24 (python)

Issue:
1.5.5+ Batch сломался
``` python
        results = await b.call_batch ({
            'halt': 0,
            'cmd': {
                'statuses': 'crm.status.entity.items?entityId=STATUS',
                'sources': 'crm.status.entity.items?entityId=SOURCE'
            }
        })
        pprint.pprint(results)
```
1.5.4:
```
dict:
{'sources': [{'NAME': 'СРМ', 'SORT': 10, 'STATUS_ID': 'CALL'},
...
             {'NAME': 'Иное', 'SORT': 60, 'STATUS_ID': '1'}],
 'statuses': [{'NAME': 'Новый лид', 'SORT': 10, 'STATUS_ID': 'NEW'},
...
              {'NAME': 'Внутренняя почта', 'SORT': 90, 'STATUS_ID': '4'}
]}
```
1.5.5
```
merged list:
[{'NAME': 'Новый лид', 'SORT': 10, 'STATUS_ID': 'NEW'},
...
 {'NAME': 'Внутренняя почта', 'SORT': 90, 'STATUS_ID': '4'}
 {'NAME': 'СРМ', 'SORT': 10, 'STATUS_ID': 'CALL'},
...
 {'NAME': 'Иное', 'SORT': 60, 'STATUS_ID': '1'}]
```

Pull request description:
1.5.5+ Batch сломался
Fixes #177

Patch:
diff --git a/API.md b/API.md
index 9160219..6168dc6 100644
--- a/API.md
+++ b/API.md
@@ -116,7 +116,7 @@ all_lead_info = b.list_and_get('crm.lead')
 
 * `items: dict | Iterable[dict]` - параметры вызываемого метода. Может быть списком, и тогда метод будет вызван для каждого элемента списка, а может быть одним словарем параметров для единичного вызова.
 
-* `raw: bool = False` - если `True`, то `items` воспринимается как один элемент (даже если в `items` был передан список) и не заворачивается в батч. Требуется для работы со старыми методами, принимающими на вход параметры списком (`tasks.elapseditem.*`), а также для передачи значений `None` (которые плохо обрабатываются в батче). Подробней см. [PR #158](https://github.com/leshchenko1979/fast_bitrix24/pull/158). По умолчанию - `False`.
+* `raw: bool = False` - если `True`, то `items` воспринимается как один элемент (даже если в `items` был передан список) и не заворачивается в батч. Требуется для работы со старыми методами, принимающими на вход параметры списком (`tasks.elapseditem.*`), а также для передачи значений `None` (которые плохо обрабатываются в батче). Подробней см. [PR #158](https://github.com/leshchenko1979/fast_bitrix24/pull/158). При этом возвращается необработанный ответ сервера в виде словаря. По умолчанию - `False`.
 
 Если `raw=False`, то `call()` вызывает `method`, последовательно подставляя в параметры запроса все элементы `items`, и возвращает список ответов сервера для каждого из отправленных запросов. При этом запросы к Битриксу группируются в батчи. Либо, если `items` - не список, а словарь с параметрами, то происходит единичный вызов и возвращается его результат.
 
diff --git a/README.md b/README.md
index 0294a71..2a489f8 100644
--- a/README.md
+++ b/README.md
@@ -126,14 +126,11 @@ b.call('crm.deal.update', tasks)
 Метод [`call()`](API.md#метод-callself-method-str-items-dict--iterabledict--any--none--raw-bool--false---dict--listdict--any) возвращает список ответов сервера по каждому элементу переданного списка.
 
 ### call(raw=True)
-Рекомендуется использовать метод `call(raw=True)` в следующих случаях:
-- для вызова методов типа `crm.lead.fields` или `crm.deal.fields`, которые не требуют дополнительных параметров и возвращают словарь (`dict`), а не список (`list`),
-- для отправки запросов, в параметрах которых есть `None` (для стирания значения полей).
+Вызов `call` с парамтером `raw=True` отправляет на сервер переданные ему параметры в оригинальном, необработанном виде (пропуская упаковку в батчи), и возвращает ответ сервера без какой-либо обработки.
 
-```python
-# вернуть dict с полями лида
-b.call('crm.lead.fields', raw=True)
+Подобный вызов можно использовать в отладочных целях, но кроме того, придется его использовать для отправки запросов, в параметрах которых есть `None` (None применяется для стирания значения полей, а упаковка в батчи мешает передавать `None`).
 
+```python
 # стереть DESCRIPTION в лиде 123
 params = {"ID": 123, "fields": {"DESCRIPTION": None}}
 b.call('crm.lead.update', params, raw=True)
diff --git a/fast_bitrix24/bitrix.py b/fast_bitrix24/bitrix.py
index 90df4e9..0e1448e 100644
--- a/fast_bitrix24/bitrix.py
+++ b/fast_bitrix24/bitrix.py
@@ -6,20 +6,20 @@ from contextlib import contextmanager
 from typing import Iterable, Union
 
 import aiohttp
-from beartype import beartype
 import icontract
+from beartype import beartype
 
 from . import correct_asyncio
+from .logger import log, logger
+from .server_response import ServerResponseParser
 from .srh import ServerRequestHandler
 from .user_request import (
-    BatchUserRequest,
     CallUserRequest,
     GetAllUserRequest,
     GetByIDUserRequest,
     ListAndGetUserRequest,
     RawCallUserRequest,
 )
-from .logger import log, logger
 
 
 class BitrixAsync:
@@ -179,7 +179,15 @@ class BitrixAsync:
         команды, а значение - ответ сервера по этой команде.
         """
 
-        return await self.srh.run_async(BatchUserRequest(self, params).run())
+        response = ServerResponseParser(
+            await self.srh.run_async(RawCallUserRequest(self, "batch", params).run())
+        )
+
+        errors = response.extract_errors()
+        if errors:
+            raise RuntimeError(errors)
+
+        return response.result["result"]
 
     @contextmanager
     @beartype
diff --git a/fast_bitrix24/server_response.py b/fast_bitrix24/server_response.py
index 949dcd0..72de168 100644
--- a/fast_bitrix24/server_response.py
+++ b/fast_bitrix24/server_response.py
@@ -3,6 +3,7 @@ from itertools import chain
 
 from beartype.typing import Dict, List, Union
 
+
 class ServerResponseParser:
     def __init__(self, response: dict):
         self.response = response
@@ -26,6 +27,15 @@ class ServerResponseParser:
     def result_error(self):
         return self.response.get("result_error")
 
+    def extract_errors(self):
+        if self.is_batch():
+            if self.result.get("result_error"):
+                return self.result["result_error"]
+        elif self.result_error:
+            return self.result_error
+
+        return None
+
     def extract_results(self) -> Union[Dict, List[Dict]]:
         """Вернуть результаты запроса.
 
@@ -35,13 +45,13 @@ class ServerResponseParser:
         Returns:
             Any: Результаты запроса, по возможности превращенные в плоский список.
         """
+        errors = self.extract_errors()
+        if errors:
+            raise RuntimeError(errors)
+
         if self.is_batch():
-            if self.result.get("result_error"):
-                raise RuntimeError(self.result["result_error"])
             return self.extract_from_batch_response(self.result["result"])
         else:
-            if self.result_error:
-                raise RuntimeError(self.result_error)
             return self.extract_from_single_response(self.result)
 
     def is_batch(self) -> bool:
diff --git a/fast_bitrix24/user_request.py b/fast_bitrix24/user_request.py
index 39b4497..61cd338 100644
--- a/fast_bitrix24/user_request.py
+++ b/fast_bitrix24/user_request.py
@@ -228,7 +228,8 @@ class CallUserRequest(GetByIDUserRequest):
     @icontract.require(lambda self: self.item_list, "call(): item_list can't be empty")
     @icontract.require(
         lambda self: not self.bitrix.verbose
-        or not self.ID_list or "__len__" in dir(self.ID_list),
+        or not self.ID_list
+        or "__len__" in dir(self.ID_list),
         "call(): 'ID_list' should be a Sequence "
         "if a progress bar is to be displayed",
     )
@@ -277,31 +278,16 @@ class RawCallUserRequest(UserRequestAbstract):
         """Пропускаем все проверки и изменения параметров."""
         return p
 
-    def check_special_limitations(self):
-        return True
-
-
-class BatchUserRequest(UserRequestAbstract):
-    @beartype
-    @icontract.require(lambda params: params, "call_batch(): params can't be empty")
-    def __init__(self, bitrix, params: Dict):
-        super().__init__(bitrix, "batch", params)
-
-    def standardized_method(self, method):
-        return "batch"
+    def standardized_method(self, method: str):
+        """Пропускаем все проверки и изменения методов."""
+        return method
 
-    @icontract.require(
-        lambda self: self.st_params.keys() == {"HALT", "CMD"},
-        "call_batch(): params should contain only 'halt' and 'cmd' "
-        "clauses at the highest level",
-    )
-    @icontract.require(
-        lambda self: isinstance(self.st_params["CMD"], dict),
-        "call_batch(): 'cmd' clause should contain a dict",
-    )
     def check_special_limitations(self):
         return True
 
+    async def run(self):
+        return await self.srh.single_request(self.method, self.params)
+
 
 class ListAndGetUserRequest:
     @beartype
diff --git a/setup.py b/setup.py
index e068ef9..5a46968 100644
--- a/setup.py
+++ b/setup.py
@@ -8,7 +8,7 @@ with open('requirements.txt', 'r', encoding='utf-8') as fh:
 
 setuptools.setup(
     name="fast_bitrix24",
-    version="1.5.6",
+    version="1.5.7",
     author="Alexey Leshchenko",
     author_email="leshchenko@gmail.com",
     description='API wrapper для быстрого получения данных от Битрикс24 через '


Test patch:
diff --git a/tests/real_responses/call_batch.py b/tests/real_responses/call_batch.py
new file mode 100644
index 0000000..a2eca68
--- /dev/null
+++ b/tests/real_responses/call_batch.py
@@ -0,0 +1,64 @@
+response = {
+    "result": {
+        "result": {
+            "statuses": [
+                {"NAME": "Новый лид", "SORT": 10, "STATUS_ID": "NEW"},
+                {"NAME": "Рубрикация лида", "SORT": 20, "STATUS_ID": "IN_PROCESS"},
+                {
+                    "NAME": "Назначение ответственного",
+                    "SORT": 30,
+                    "STATUS_ID": "PROCESSED",
+                },
+                {"NAME": "Повторный контакт", "SORT": 40, "STATUS_ID": "1"},
+                {"NAME": "Качественный лид", "SORT": 50, "STATUS_ID": "CONVERTED"},
+                {"NAME": "Некачественный лид", "SORT": 60, "STATUS_ID": "JUNK"},
+                {"NAME": "Принадлежит МП вне BX24", "SORT": 70, "STATUS_ID": "2"},
+                {"NAME": "Задвоенные лиды", "SORT": 80, "STATUS_ID": "3"},
+                {"NAME": "Внутренняя почта", "SORT": 90, "STATUS_ID": "4"},
+                {
+                    "NAME": "Сперва позвонили, а потом завели контакт и компанию",
+                    "SORT": 100,
+                    "STATUS_ID": "5",
+                },
+            ],
+            "sources": [
+                {"NAME": "Портальная СРМ", "SORT": 10, "STATUS_ID": "CALL"},
+                {"NAME": "Электронная почта", "SORT": 20, "STATUS_ID": "WEBFORM"},
+                {"NAME": "Входящий звонок", "SORT": 30, "STATUS_ID": "CALLBACK"},
+                {"NAME": "Письмо (бумажное)", "SORT": 40, "STATUS_ID": "RC_GENERATOR"},
+                {"NAME": "Задача от маркетинга", "SORT": 50, "STATUS_ID": "STORE"},
+                {"NAME": "Иное", "SORT": 60, "STATUS_ID": "1"},
+                {"NAME": "Запрос с сайта", "SORT": 70, "STATUS_ID": "2"},
+            ],
+        },
+        "result_error": [],
+        "result_total": [],
+        "result_next": [],
+        "result_time": {
+            "statuses": {
+                "start": 1654518286.547828,
+                "finish": 1654518286.55755,
+                "duration": 0.009721994400024414,
+                "processing": 0.009358882904052734,
+                "date_start": "2022-06-06T15:24:46+03:00",
+                "date_finish": "2022-06-06T15:24:46+03:00",
+            },
+            "sources": {
+                "start": 1654518286.55769,
+                "finish": 1654518286.558538,
+                "duration": 0.0008480548858642578,
+                "processing": 0.0006170272827148438,
+                "date_start": "2022-06-06T15:24:46+03:00",
+                "date_finish": "2022-06-06T15:24:46+03:00",
+            },
+        },
+    },
+    "time": {
+        "start": 1654518286.426799,
+        "finish": 1654518286.558576,
+        "duration": 0.13177704811096191,
+        "processing": 0.010885000228881836,
+        "date_start": "2022-06-06T15:24:46+03:00",
+        "date_finish": "2022-06-06T15:24:46+03:00",
+    },
+}
diff --git a/tests/test_server_responses.py b/tests/test_server_responses.py
index fc5f739..d264313 100644
--- a/tests/test_server_responses.py
+++ b/tests/test_server_responses.py
@@ -84,3 +84,34 @@ def test_get_all_non_list_method(bx_dummy):
     bx_dummy.srh = MockSRH(response)
     results = bx_dummy.get_all("user.fields")
     assert isinstance(results, dict)
+
+
+def test_batch_and_call_raw(bx_dummy):
+    from tests.real_responses.call_batch import response
+
+    bx_dummy.srh = MockSRH(response)
+    results = bx_dummy.call_batch(
+        {
+            "halt": 0,
+            "cmd": {
+                "statuses": "crm.status.entity.items?entityId=STATUS",
+                "sources": "crm.status.entity.items?entityId=SOURCE",
+            },
+        }
+    )
+    assert isinstance(results, dict)
+    assert results.keys() == {"statuses", "sources"}
+
+    bx_dummy.srh = MockSRH(response)
+    results = bx_dummy.call("batch",
+        {
+            "halt": 0,
+            "cmd": {
+                "statuses": "crm.status.entity.items?entityId=STATUS",
+                "sources": "crm.status.entity.items?entityId=SOURCE",
+            },
+        },
+        raw=True
+    )
+    assert isinstance(results, dict)
+    assert results.keys() == {"result", "time"}
diff --git a/tests/test_sync.py b/tests/test_sync.py
index aaab8d0..7fb2c1b 100644
--- a/tests/test_sync.py
+++ b/tests/test_sync.py
@@ -233,7 +233,7 @@ class TestErrors:
         b = bx_dummy
 
         async def stub(*args, **kwargs):
-            return {"result": "ok"}
+            return {"result": {"result": {"ok"}}}
 
         monkeypatch.setattr(b.srh, "request_attempt", stub)
         assert b.srh.request_attempt is stub