onecdc 0.2.0__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.
- onecdc/__init__.py +20 -0
- onecdc/__main__.py +108 -0
- onecdc/change_reader.py +130 -0
- onecdc/common_functions.py +187 -0
- onecdc/cron_runner.py +231 -0
- onecdc/data_reader.py +1174 -0
- onecdc/db_logs.py +104 -0
- onecdc/db_writer.py +287 -0
- onecdc/full_load_claim.py +163 -0
- onecdc/full_load_keys.py +164 -0
- onecdc/handlers.py +1089 -0
- onecdc/logging_config.py +79 -0
- onecdc/metadata_reader.py +597 -0
- onecdc/name_mapper.py +94 -0
- onecdc/py.typed +0 -0
- onecdc/replicator.py +1614 -0
- onecdc/stop_signal.py +86 -0
- onecdc/write_tracker.py +223 -0
- onecdc-0.2.0.dist-info/METADATA +261 -0
- onecdc-0.2.0.dist-info/RECORD +23 -0
- onecdc-0.2.0.dist-info/WHEEL +4 -0
- onecdc-0.2.0.dist-info/entry_points.txt +2 -0
- onecdc-0.2.0.dist-info/licenses/LICENSE +21 -0
onecdc/__init__.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from importlib.metadata import version, PackageNotFoundError
|
|
2
|
+
|
|
3
|
+
try:
|
|
4
|
+
__version__ = version("onecdc")
|
|
5
|
+
except PackageNotFoundError:
|
|
6
|
+
__version__ = "dev"
|
|
7
|
+
|
|
8
|
+
from .metadata_reader import MetadataReader
|
|
9
|
+
from .data_reader import DataReader, DataObject
|
|
10
|
+
from .change_reader import ChangeReader
|
|
11
|
+
from .name_mapper import NameMapper
|
|
12
|
+
from .db_writer import DBWriter
|
|
13
|
+
from .handlers import Handler, HandlerContext, HandlerLoop
|
|
14
|
+
from .replicator import Replicator
|
|
15
|
+
from .cron_runner import FullLoadCron
|
|
16
|
+
|
|
17
|
+
__all__ = ["MetadataReader", "DataReader", "DataObject", "ChangeReader", "NameMapper",
|
|
18
|
+
"DBWriter", "Handler", "HandlerContext", "HandlerLoop", "Replicator",
|
|
19
|
+
"FullLoadCron", "__version__"]
|
|
20
|
+
|
onecdc/__main__.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Entrypoint для запуска из окружения без единой строки своего кода: `python -m onecdc` или команда
|
|
3
|
+
`onecdc`. Все настройки — переменные окружения ONECDC_*; режим задаёт ONECDC_MODE: `loop`
|
|
4
|
+
(по умолчанию) запускает run_forever с периодом ONECDC_POLL_INTERVAL, `once` — один run_once.
|
|
5
|
+
|
|
6
|
+
Обязательные: ONECDC_ODATA_URL, ONECDC_EXCHANGE_NAME, ONECDC_QUEUE_GUID, ONECDC_DB_URL.
|
|
7
|
+
ONECDC_QUEUE_GUID не знаете — запустите без него: в лог выведется список узлов плана обмена.
|
|
8
|
+
Необязательные: ONECDC_ODATA_USER, ONECDC_ODATA_PASSWORD (без пользователя — без авторизации),
|
|
9
|
+
ONECDC_DB_SCHEMA, ONECDC_DB_TEMP_SCHEMA, ONECDC_FULL_LOAD_WORKERS, ONECDC_POLL_INTERVAL, ONECDC_LOG_LEVEL, ONECDC_MODE.
|
|
10
|
+
|
|
11
|
+
Обработчиков здесь нет: они объявляются кодом, а тут кода пользователя нет. Нужны обработчики —
|
|
12
|
+
берите за основу config/runner.py: там ровно та же сборка, плюс по HandlerLoop на каждого
|
|
13
|
+
обработчика, а значения при желании заменяются литералами.
|
|
14
|
+
"""
|
|
15
|
+
import logging
|
|
16
|
+
import os
|
|
17
|
+
|
|
18
|
+
import requests
|
|
19
|
+
from sqlalchemy import create_engine
|
|
20
|
+
|
|
21
|
+
from onecdc.replicator import Replicator
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _required(name: str) -> str:
|
|
25
|
+
"""Обязательная переменная окружения. KeyError в трейсбеке ничего не объясняет тому, кто
|
|
26
|
+
запускает контейнер, — говорим прямо, чего не хватает."""
|
|
27
|
+
value = os.environ.get(name, "").strip()
|
|
28
|
+
if not value:
|
|
29
|
+
raise SystemExit(f"{name} is not set (required: ONECDC_ODATA_URL, ONECDC_EXCHANGE_NAME, "
|
|
30
|
+
f"ONECDC_DB_URL)")
|
|
31
|
+
return value
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _number(name: str, default: str, cast=float):
|
|
35
|
+
"""Числовая переменная окружения с понятным сообщением вместо голого ValueError."""
|
|
36
|
+
value = os.environ.get(name, default).strip() or default
|
|
37
|
+
try:
|
|
38
|
+
number = cast(value)
|
|
39
|
+
except ValueError:
|
|
40
|
+
raise SystemExit(f"{name}={value!r} is not a number")
|
|
41
|
+
if number <= 0:
|
|
42
|
+
raise SystemExit(f"{name}={value!r} must be positive")
|
|
43
|
+
return number
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def main() -> None:
|
|
47
|
+
odata_user = os.environ.get("ONECDC_ODATA_USER")
|
|
48
|
+
odata_url = _required("ONECDC_ODATA_URL")
|
|
49
|
+
full_load_workers = _number("ONECDC_FULL_LOAD_WORKERS", "2", int)
|
|
50
|
+
|
|
51
|
+
mode = os.environ.get("ONECDC_MODE", "loop")
|
|
52
|
+
if mode not in ("loop", "once"):
|
|
53
|
+
# Проверяем до соединений с БД и 1С: незачем поднимать пул ради заведомо неверного режима.
|
|
54
|
+
raise SystemExit(f"Unknown ONECDC_MODE={mode!r} (expected 'loop' or 'once')")
|
|
55
|
+
|
|
56
|
+
# Пул: одновременно соединение держат цикл изменений, ДВА потока отметки живости (реестр
|
|
57
|
+
# незавершённых merge и захват полной выгрузки) и страницы полной выгрузки, отсюда
|
|
58
|
+
# full_load_workers + 3 (см. README_DB.md, «Сколько нужно соединений к БД»). Отметке живости,
|
|
59
|
+
# не получившей соединения дольше своего TTL, никто не верит: её захват или её границу окна
|
|
60
|
+
# считают брошенными.
|
|
61
|
+
# Обработчиков здесь нет — были бы, добавилось бы по соединению на каждого.
|
|
62
|
+
engine = create_engine(_required("ONECDC_DB_URL"), pool_size=full_load_workers + 3)
|
|
63
|
+
|
|
64
|
+
# Параметры присваиваются явно, по одному — как и в config/runner.py: сборка одинаково
|
|
65
|
+
# читается и здесь, и в пользовательском коде, где значения будут литералами.
|
|
66
|
+
# Недоступная БД — не ошибка в коде, а состояние окружения: показываем строку, ради которой
|
|
67
|
+
# человек и полез бы в стотридцатистрочный трейс (см. _check_db_connection в replicator).
|
|
68
|
+
try:
|
|
69
|
+
replicator = Replicator(
|
|
70
|
+
odata_url=odata_url,
|
|
71
|
+
odata_auth=(odata_user, os.environ.get("ONECDC_ODATA_PASSWORD", "")) if odata_user else None,
|
|
72
|
+
exchange_name=_required("ONECDC_EXCHANGE_NAME"),
|
|
73
|
+
# Без узла обмена работать нельзя, но KeyError тут ничего не подскажет: пустое значение
|
|
74
|
+
# дойдёт до чтения изменений, и оно выведет в лог список узлов плана обмена.
|
|
75
|
+
queue_guid=os.environ.get("ONECDC_QUEUE_GUID", ""),
|
|
76
|
+
engine=engine,
|
|
77
|
+
db_schema=os.environ.get("ONECDC_DB_SCHEMA"),
|
|
78
|
+
# Схема промежуточных таблиц dbmerge; не задана — та же, что у данных.
|
|
79
|
+
db_temp_schema=os.environ.get("ONECDC_DB_TEMP_SCHEMA"),
|
|
80
|
+
full_load_workers=full_load_workers,
|
|
81
|
+
)
|
|
82
|
+
except ConnectionError as exc:
|
|
83
|
+
raise SystemExit(str(exc))
|
|
84
|
+
|
|
85
|
+
# Уровень логирования — после конструктора: он вешает обработчик на логгер onecdc
|
|
86
|
+
# (по умолчанию INFO), а тут переопределяем на заданный (например, DEBUG/WARNING).
|
|
87
|
+
log_level = os.environ.get("ONECDC_LOG_LEVEL", "INFO").strip().upper() or "INFO"
|
|
88
|
+
# getLevelName на известное имя отвечает числом, на неизвестное — строкой "Level FOO".
|
|
89
|
+
# Не getLevelNamesMapping(): он появился только в 3.11, а поддерживаем с 3.10.
|
|
90
|
+
level = logging.getLevelName(log_level)
|
|
91
|
+
if not isinstance(level, int):
|
|
92
|
+
raise SystemExit(f"Unknown ONECDC_LOG_LEVEL={log_level!r} "
|
|
93
|
+
"(expected DEBUG/INFO/WARNING/ERROR/CRITICAL)")
|
|
94
|
+
logging.getLogger("onecdc").setLevel(level)
|
|
95
|
+
|
|
96
|
+
# run_forever недоступную 1С переживает сам (логирует и повторяет с backoff), а run_once
|
|
97
|
+
# обязан отдать ошибку наружу — здесь она и превращается в строку вместо трейса.
|
|
98
|
+
try:
|
|
99
|
+
if mode == "once":
|
|
100
|
+
replicator.run_once()
|
|
101
|
+
else:
|
|
102
|
+
replicator.run_forever(interval=_number("ONECDC_POLL_INTERVAL", "60"))
|
|
103
|
+
except requests.RequestException as exc:
|
|
104
|
+
raise SystemExit(f"1C is not available at {odata_url}: {exc}")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
if __name__ == "__main__":
|
|
108
|
+
main()
|
onecdc/change_reader.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import requests
|
|
2
|
+
|
|
3
|
+
import xmltodict
|
|
4
|
+
|
|
5
|
+
from onecdc.data_reader import DataReader
|
|
6
|
+
from onecdc.metadata_reader import ACCOUNTING_REGISTER_TYPE, MetadataReader, resolve_timeout
|
|
7
|
+
from onecdc.common_functions import format_bytes, raise_for_status
|
|
8
|
+
from onecdc.logging_config import get_logger
|
|
9
|
+
|
|
10
|
+
logger = get_logger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ChangeReader(DataReader):
|
|
14
|
+
def __init__(self, odata_url: str, exchange_name: str, queue_guid: str,
|
|
15
|
+
metadata: MetadataReader, odata_auth: tuple[str, str] | None = None,
|
|
16
|
+
request_timeout: float | None = None):
|
|
17
|
+
super().__init__(odata_url, metadata, odata_auth, request_timeout)
|
|
18
|
+
self.exchange_name = exchange_name
|
|
19
|
+
self.queue_guid = queue_guid
|
|
20
|
+
self.message_no = 0
|
|
21
|
+
|
|
22
|
+
def read_changes(self):
|
|
23
|
+
# Узел не задан — дальше идти бессмысленно: подсказываем, из чего выбирать.
|
|
24
|
+
if not self.queue_guid:
|
|
25
|
+
self._raise_no_queue_guid()
|
|
26
|
+
# Сбрасываем накопленные данные предыдущего цикла (важно для run_forever).
|
|
27
|
+
self.clear()
|
|
28
|
+
self.message_no = self.get_last_received_no()+1
|
|
29
|
+
self.exchange_message_no = self.message_no
|
|
30
|
+
|
|
31
|
+
logger.info(f"Reading changes from 1C (message {self.message_no})")
|
|
32
|
+
|
|
33
|
+
url = f"{self.odata_url}/SelectChanges?DataExchangePoint='{self.odata_url}/ExchangePlan_{self.exchange_name}(guid'{self.queue_guid}')'&MessageNo={self.message_no}"
|
|
34
|
+
|
|
35
|
+
response = requests.post(url,auth=self.odata_auth,timeout=resolve_timeout(self.request_timeout))
|
|
36
|
+
raise_for_status(response, f'SelectChanges (message {self.message_no})')
|
|
37
|
+
self.last_response_bytes = len(response.content)
|
|
38
|
+
|
|
39
|
+
change_data = xmltodict.parse(response.text,force_list=('d:element','entry'))
|
|
40
|
+
change_entries = (change_data.get('feed') or {}).get('entry') or []
|
|
41
|
+
|
|
42
|
+
parsed = self.read_data_entries(change_entries)
|
|
43
|
+
# Пакет приносит набор записей регистра бухгалтерии БЕЗ субконто (их нет в описании
|
|
44
|
+
# движения вовсе) — дочитываем их отдельным запросом по периодам пакета, иначе колонки
|
|
45
|
+
# приехали бы пустыми и затёрли аналитику, полученную полной выгрузкой.
|
|
46
|
+
for object_name in list(self.keys()):
|
|
47
|
+
if object_name.startswith(ACCOUNTING_REGISTER_TYPE):
|
|
48
|
+
self.fill_ext_dimensions(object_name)
|
|
49
|
+
# Одна строка на пакет: что пришло, сколько строк и сколько весил ответ. Раньше лог писался
|
|
50
|
+
# на каждую entry, и один пакет давал сотни одинаковых строк.
|
|
51
|
+
logger.info("Read changes (message %s): %s entries, %s rows, %s%s",
|
|
52
|
+
self.message_no, len(change_entries), self.rows_read(),
|
|
53
|
+
format_bytes(self.last_response_bytes),
|
|
54
|
+
''.join(f'\n {name}: {n} entries' for name, n in parsed.items()))
|
|
55
|
+
|
|
56
|
+
def notify_changes_received(self):
|
|
57
|
+
"""
|
|
58
|
+
Подтвердить получение изменений, отправив запрос на сервер
|
|
59
|
+
"""
|
|
60
|
+
url = f"{self.odata_url}/NotifyChangesReceived?DataExchangePoint='{self.odata_url}/ExchangePlan_{self.exchange_name}(guid'{self.queue_guid}')'&MessageNo={self.message_no}"
|
|
61
|
+
response = requests.post(url,auth=self.odata_auth,timeout=resolve_timeout(self.request_timeout))
|
|
62
|
+
# Не-2xx -> HTTPError. Подтверждение не прошло — изменения не списаны и придут снова
|
|
63
|
+
# (в run_forever цикл повторится, save идемпотентен).
|
|
64
|
+
raise_for_status(response, f'NotifyChangesReceived (message {self.message_no})')
|
|
65
|
+
logger.info(f"Changes confirmed for queue {self.queue_guid} (message {self.message_no})")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def read_nodes(self) -> list[dict]:
|
|
69
|
+
"""
|
|
70
|
+
Все узлы плана обмена (как их отдаёт OData), включая ЭтотУзел.
|
|
71
|
+
"""
|
|
72
|
+
url = f"{self.odata_url}/ExchangePlan_{self.exchange_name}?$format=json"
|
|
73
|
+
response = requests.get(url,auth=self.odata_auth,timeout=resolve_timeout(self.request_timeout))
|
|
74
|
+
raise_for_status(response, f'ExchangePlan_{self.exchange_name}')
|
|
75
|
+
return response.json().get('value') or []
|
|
76
|
+
|
|
77
|
+
def available_nodes(self) -> list[dict]:
|
|
78
|
+
"""
|
|
79
|
+
Узлы, которые можно указать в queue_guid: всё, кроме ЭтотУзел (ThisNode) — он описывает
|
|
80
|
+
саму базу-источник, подписаться на его изменения нельзя.
|
|
81
|
+
"""
|
|
82
|
+
return [node for node in self.read_nodes() if not node.get('ThisNode')]
|
|
83
|
+
|
|
84
|
+
def _raise_no_queue_guid(self):
|
|
85
|
+
"""
|
|
86
|
+
queue_guid не задан: выводим в лог список узлов плана обмена, чтобы guid можно было взять
|
|
87
|
+
прямо отсюда, не открывая конфигуратор и не имея обработки в 1С.
|
|
88
|
+
"""
|
|
89
|
+
try:
|
|
90
|
+
nodes = self.available_nodes()
|
|
91
|
+
except Exception as error:
|
|
92
|
+
# Список не получили (нет связи / неверный exchange_name) — сообщение об этом не должно
|
|
93
|
+
# подменять собой главную ошибку: она всё равно ниже.
|
|
94
|
+
logger.warning("Could not list nodes of exchange plan %s: %s", self.exchange_name, error)
|
|
95
|
+
else:
|
|
96
|
+
if nodes:
|
|
97
|
+
logger.error("queue_guid is not set. Available nodes of exchange plan %s:%s",
|
|
98
|
+
self.exchange_name,
|
|
99
|
+
''.join(f"\n {node.get('Ref_Key')} {node.get('Code') or ''}"
|
|
100
|
+
f" {node.get('Description') or ''}".rstrip()
|
|
101
|
+
for node in nodes))
|
|
102
|
+
else:
|
|
103
|
+
logger.error("queue_guid is not set, and exchange plan %s has no nodes besides "
|
|
104
|
+
"ThisNode: create a node for this replication in 1C",
|
|
105
|
+
self.exchange_name)
|
|
106
|
+
raise ValueError(f"queue_guid is not set (exchange plan {self.exchange_name}): "
|
|
107
|
+
"specify the Ref_Key of the exchange node")
|
|
108
|
+
|
|
109
|
+
def get_last_received_no(self)->int:
|
|
110
|
+
"""
|
|
111
|
+
Получить номер последнего пакета обмена, который был получен и подтвержден
|
|
112
|
+
"""
|
|
113
|
+
queues = self.read_nodes()
|
|
114
|
+
receive_no = 0
|
|
115
|
+
found = False
|
|
116
|
+
|
|
117
|
+
for queue in queues:
|
|
118
|
+
if self.queue_guid == queue['Ref_Key']:
|
|
119
|
+
receive_no = int(queue['ReceivedNo'])
|
|
120
|
+
found = True
|
|
121
|
+
|
|
122
|
+
if not found:
|
|
123
|
+
# Очередь по guid не нашлась — вернём 0 (запросится пакет №1), но это почти наверняка
|
|
124
|
+
# неверный queue_guid или план обмена: без предупреждения ошибку конфигурации не видно.
|
|
125
|
+
logger.warning("Exchange queue %s not found in plan %s (check queue_guid/exchange_name)",
|
|
126
|
+
self.queue_guid, self.exchange_name)
|
|
127
|
+
|
|
128
|
+
return receive_no
|
|
129
|
+
|
|
130
|
+
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import re
|
|
3
|
+
|
|
4
|
+
from datetime import date, datetime
|
|
5
|
+
|
|
6
|
+
import requests
|
|
7
|
+
from sqlalchemy import DateTime, func
|
|
8
|
+
|
|
9
|
+
from onecdc.logging_config import get_logger
|
|
10
|
+
|
|
11
|
+
ODATA_PREFIX = 'StandardODATA.'
|
|
12
|
+
|
|
13
|
+
# Часы БД без часового пояса. Просто now() не годится: PostgreSQL отдаёт timestamptz, драйвер —
|
|
14
|
+
# offset-aware datetime, а merged_on, started_at и onecdc_handlers.last_run_at лежат в колонках без
|
|
15
|
+
# пояса и читаются offset-naive. Сравнить такие значения в Python нельзя — «can't compare
|
|
16
|
+
# offset-naive and offset-aware datetimes», — а сравниваются они постоянно (граница окна против
|
|
17
|
+
# last_run_at, guard'ы полной выгрузки против merged_on).
|
|
18
|
+
#
|
|
19
|
+
# Приведение делает сама БД, а не Python: там же, где живут эти часы, и ровно так же, как при
|
|
20
|
+
# записи timestamptz в колонку timestamp — перевод в часовой пояс сессии, затем отбрасывание
|
|
21
|
+
# смещения. Значение при этом не меняется, меняется только его тип.
|
|
22
|
+
DB_NOW_WITHOUT_TIMEZONE = func.now().cast(DateTime)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
logger = get_logger(__name__)
|
|
26
|
+
|
|
27
|
+
# Предел длины описания ошибки в логе — на случай, если распознать формат не удалось и в лог идёт
|
|
28
|
+
# сырое тело. У распознанных ответов описание короткое, до предела не доходит.
|
|
29
|
+
MAX_ERROR_BODY_CHARS = 500
|
|
30
|
+
|
|
31
|
+
BYTE_UNITS = ('B', 'KB', 'MB', 'GB', 'TB')
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def format_bytes(size: float) -> str:
|
|
35
|
+
"""
|
|
36
|
+
Размер ответа для лога в удобной единице: байты для мелочи, дальше КБ/МБ/ГБ. Ответы 1С
|
|
37
|
+
различаются на порядки (страница справочника — килобайты, набор движений — мегабайты),
|
|
38
|
+
и в сырых байтах разницу глазом не поймать.
|
|
39
|
+
"""
|
|
40
|
+
for unit in BYTE_UNITS:
|
|
41
|
+
if size < 1024 or unit == BYTE_UNITS[-1]:
|
|
42
|
+
return f'{size:.0f} {unit}' if unit == BYTE_UNITS[0] else f'{size:.1f} {unit}'
|
|
43
|
+
size /= 1024
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def format_duration(seconds: float) -> str:
|
|
47
|
+
"""
|
|
48
|
+
Длительность для лога: секунды с десятой долей, от минуты — «1m 04s», от часа — «1h 05m 03s».
|
|
49
|
+
Обработка пакета занимает от долей секунды до десятков минут, и в сырых секундах такой разброс
|
|
50
|
+
читается плохо.
|
|
51
|
+
"""
|
|
52
|
+
if seconds < 60:
|
|
53
|
+
return f'{seconds:.1f}s'
|
|
54
|
+
minutes, sec = divmod(int(seconds), 60)
|
|
55
|
+
hours, minutes = divmod(minutes, 60)
|
|
56
|
+
if hours:
|
|
57
|
+
return f'{hours}h {minutes:02d}m {sec:02d}s'
|
|
58
|
+
return f'{minutes}m {sec:02d}s'
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _one_line(text: str) -> str:
|
|
62
|
+
"""Схлопывает переносы и лишние пробелы: описание ошибки должно занимать одну строку лога."""
|
|
63
|
+
text = re.sub(r'\s+', ' ', text).strip()
|
|
64
|
+
if len(text) > MAX_ERROR_BODY_CHARS:
|
|
65
|
+
return f'{text[:MAX_ERROR_BODY_CHARS]}... [+{len(text) - MAX_ERROR_BODY_CHARS} chars]'
|
|
66
|
+
return text
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _exception_descriptions(payload: dict) -> list[str]:
|
|
70
|
+
"""
|
|
71
|
+
Описания из цепочки exception -> inner в JSON-исключении сервера приложений 1С. Берём только
|
|
72
|
+
descr: рядом лежат creationStack (адреса в DLL) и base64-дамп на сотни строк, которые в логе
|
|
73
|
+
бесполезны. Вложенные описания часто повторяют друг друга — оставляем только те, что не
|
|
74
|
+
являются куском уже отобранного.
|
|
75
|
+
|
|
76
|
+
Внешнюю обёртку вида «HTTP: Forbidden. Ошибка при выполнении запроса GET к ресурсу …»
|
|
77
|
+
отбрасываем: код, метод и ресурс уже есть в нашем же сообщении. Но если она единственная —
|
|
78
|
+
оставляем, лучше так, чем пустая ошибка.
|
|
79
|
+
"""
|
|
80
|
+
kept: list[str] = []
|
|
81
|
+
node = payload.get('exception') or payload
|
|
82
|
+
while isinstance(node, dict):
|
|
83
|
+
descr = _one_line(node.get('descr') or '')
|
|
84
|
+
if descr and not any(descr in text for text in kept):
|
|
85
|
+
kept = [text for text in kept if text not in descr]
|
|
86
|
+
kept.append(descr)
|
|
87
|
+
node = node.get('inner')
|
|
88
|
+
|
|
89
|
+
meaningful = [text for text in kept if not text.startswith('HTTP: ')]
|
|
90
|
+
return meaningful or kept
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def extract_error_text(body: str) -> str:
|
|
94
|
+
"""
|
|
95
|
+
Человекочитаемое описание ошибки из ответа 1С. Отвечает она тремя разными способами:
|
|
96
|
+
|
|
97
|
+
- ошибка OData: XML `<m:error><m:message>…</m:message></m:error>`;
|
|
98
|
+
- исключение сервера приложений: JSON с цепочкой exception/inner, где полезен только descr;
|
|
99
|
+
- ошибка платформы/веб-сервера: HTML «1C:Enterprise 8 application error … by reason: …».
|
|
100
|
+
|
|
101
|
+
Если формат не распознан, отдаём тело как есть (обрезанное). В любом случае результат —
|
|
102
|
+
одна строка: полный дамп тела в лог не нужен, там мегабайты служебного мусора.
|
|
103
|
+
"""
|
|
104
|
+
text = (body or '').lstrip('').strip()
|
|
105
|
+
if not text:
|
|
106
|
+
return '<empty body>'
|
|
107
|
+
|
|
108
|
+
match = re.search(r'<m:message[^>]*>(.*?)</m:message>', text, re.S)
|
|
109
|
+
if match:
|
|
110
|
+
return _one_line(match.group(1))
|
|
111
|
+
|
|
112
|
+
if text.startswith('{'):
|
|
113
|
+
try:
|
|
114
|
+
payload = json.loads(text)
|
|
115
|
+
except ValueError:
|
|
116
|
+
payload = None
|
|
117
|
+
if isinstance(payload, dict):
|
|
118
|
+
descriptions = _exception_descriptions(payload)
|
|
119
|
+
if descriptions:
|
|
120
|
+
return ' | '.join(descriptions)
|
|
121
|
+
|
|
122
|
+
match = re.search(r'by reason:\s*</b>\s*<br>(.*?)</body>', text, re.S | re.I)
|
|
123
|
+
if match:
|
|
124
|
+
return _one_line(re.sub(r'<[^>]+>', ' ', match.group(1)))
|
|
125
|
+
|
|
126
|
+
return _one_line(text)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def odata_datetime_value(value: "date | datetime") -> str:
|
|
130
|
+
"""
|
|
131
|
+
Дата-время в виде YYYY-MM-DDTHH:MM:SS для OData-литерала datetime'…'.
|
|
132
|
+
|
|
133
|
+
Собирается вручную, а не strftime: у %Y год НЕ дополняется нулями до четырёх знаков (на этой
|
|
134
|
+
платформе datetime(1,1,1).strftime('%Y-…') даёт '1-01-01'), а 1С такой литерал отвергает —
|
|
135
|
+
400 «Ошибка при разборе опции запроса $filter». Проверено на живой 1С: datetime'1-01-01T00:00:00'
|
|
136
|
+
→ 400, datetime'0001-01-01T00:00:00' → 200.
|
|
137
|
+
|
|
138
|
+
Это не экзотика: `0001-01-01` — ПУСТАЯ ДАТА 1С, она приходит в обычных данных (например, в
|
|
139
|
+
измерении-дате независимого регистра сведений), и литералы строятся в том числе из значений,
|
|
140
|
+
прочитанных из самой 1С (курсор по периоду, перепроверка кандидатов на пометку). Годы меньше 1000
|
|
141
|
+
встречаются и как опечатка оператора.
|
|
142
|
+
|
|
143
|
+
1С хранит дату-время с точностью до секунды, поэтому доли секунды усекаются — для 1С безопасно.
|
|
144
|
+
"""
|
|
145
|
+
return (f'{value.year:04d}-{value.month:02d}-{value.day:02d}'
|
|
146
|
+
f'T{getattr(value, "hour", 0):02d}:{getattr(value, "minute", 0):02d}'
|
|
147
|
+
f':{getattr(value, "second", 0):02d}')
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def raise_for_status(response: requests.Response, context: str = '') -> None:
|
|
151
|
+
"""
|
|
152
|
+
Замена response.raise_for_status(): всё содержательное в ответе 1С лежит в теле, а штатный
|
|
153
|
+
raise_for_status отдаёт наружу только «HTTPError: 500» и причину из логов не видно.
|
|
154
|
+
В лог и в текст HTTPError идёт разобранное описание (см. extract_error_text), а не сырое тело.
|
|
155
|
+
"""
|
|
156
|
+
if response.ok:
|
|
157
|
+
return
|
|
158
|
+
|
|
159
|
+
message = (f'1C request failed: {response.status_code} {response.reason} '
|
|
160
|
+
f'for {context or response.url}: {extract_error_text(response.text)}')
|
|
161
|
+
logger.error(message)
|
|
162
|
+
raise requests.HTTPError(message, response=response)
|
|
163
|
+
|
|
164
|
+
def parse_object_full_name(object_full_name):
|
|
165
|
+
"""
|
|
166
|
+
Очищаем имя объекта от разных префиксов, постфиксов и скобок.
|
|
167
|
+
Возвращает очищенное имя и тип объекта
|
|
168
|
+
"""
|
|
169
|
+
if object_full_name is None:
|
|
170
|
+
logger.error('Object full name is None')
|
|
171
|
+
return None, None
|
|
172
|
+
|
|
173
|
+
object_name = object_full_name
|
|
174
|
+
|
|
175
|
+
if object_name.startswith('Collection'):
|
|
176
|
+
object_name = object_name.removeprefix('Collection(')
|
|
177
|
+
object_name = object_name.removesuffix(')')
|
|
178
|
+
|
|
179
|
+
object_name = object_name.removeprefix(ODATA_PREFIX)
|
|
180
|
+
object_name = object_name.removesuffix('_RowType')
|
|
181
|
+
|
|
182
|
+
if '_' in object_name:
|
|
183
|
+
object_type = object_name.split('_')[0]
|
|
184
|
+
else:
|
|
185
|
+
logger.error(f'Object type not found in object full name {object_full_name}')
|
|
186
|
+
return None, None
|
|
187
|
+
return object_name, object_type
|