amochka 0.3.1__tar.gz → 0.3.3__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.
- {amochka-0.3.1 → amochka-0.3.3}/PKG-INFO +1 -2
- {amochka-0.3.1 → amochka-0.3.3}/amochka/client.py +113 -27
- {amochka-0.3.1 → amochka-0.3.3}/amochka.egg-info/PKG-INFO +1 -2
- {amochka-0.3.1 → amochka-0.3.3}/amochka.egg-info/requires.txt +0 -1
- {amochka-0.3.1 → amochka-0.3.3}/etl/config.py +2 -0
- {amochka-0.3.1 → amochka-0.3.3}/etl/extractors.py +1 -0
- {amochka-0.3.1 → amochka-0.3.3}/etl/loaders.py +56 -52
- {amochka-0.3.1 → amochka-0.3.3}/pyproject.toml +1 -2
- {amochka-0.3.1 → amochka-0.3.3}/README.md +0 -0
- {amochka-0.3.1 → amochka-0.3.3}/amochka/__init__.py +0 -0
- {amochka-0.3.1 → amochka-0.3.3}/amochka/etl.py +0 -0
- {amochka-0.3.1 → amochka-0.3.3}/amochka.egg-info/SOURCES.txt +0 -0
- {amochka-0.3.1 → amochka-0.3.3}/amochka.egg-info/dependency_links.txt +0 -0
- {amochka-0.3.1 → amochka-0.3.3}/amochka.egg-info/top_level.txt +0 -0
- {amochka-0.3.1 → amochka-0.3.3}/etl/__init__.py +0 -0
- {amochka-0.3.1 → amochka-0.3.3}/etl/migrations/001_create_tables.sql +0 -0
- {amochka-0.3.1 → amochka-0.3.3}/etl/run_etl.py +0 -0
- {amochka-0.3.1 → amochka-0.3.3}/etl/transformers.py +0 -0
- {amochka-0.3.1 → amochka-0.3.3}/setup.cfg +0 -0
- {amochka-0.3.1 → amochka-0.3.3}/tests/test_client.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: amochka
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.3
|
|
4
4
|
Summary: Python library for working with amoCRM API with ETL capabilities
|
|
5
5
|
Author-email: Timur <timurdt@gmail.com>
|
|
6
6
|
License: MIT
|
|
@@ -26,7 +26,6 @@ Classifier: Topic :: Internet :: WWW/HTTP
|
|
|
26
26
|
Requires-Python: >=3.6
|
|
27
27
|
Description-Content-Type: text/markdown
|
|
28
28
|
Requires-Dist: requests>=2.25.0
|
|
29
|
-
Requires-Dist: ratelimit>=2.2.0
|
|
30
29
|
Requires-Dist: psycopg2-binary>=2.9.0
|
|
31
30
|
Requires-Dist: python-dotenv>=1.0.0
|
|
32
31
|
|
|
@@ -5,7 +5,7 @@ import requests
|
|
|
5
5
|
import logging
|
|
6
6
|
from datetime import datetime
|
|
7
7
|
from typing import Iterator, List, Optional, Sequence, Union
|
|
8
|
-
|
|
8
|
+
# ratelimit больше не используется - rate limiting реализован вручную через self.rate_limit
|
|
9
9
|
|
|
10
10
|
# Создаём базовый логгер
|
|
11
11
|
logger = logging.getLogger(__name__)
|
|
@@ -15,7 +15,7 @@ if not logger.handlers:
|
|
|
15
15
|
ch.setFormatter(formatter)
|
|
16
16
|
logger.addHandler(ch)
|
|
17
17
|
|
|
18
|
-
|
|
18
|
+
DEFAULT_RATE_LIMIT = 7 # Максимум запросов в секунду по умолчанию
|
|
19
19
|
|
|
20
20
|
class Deal(dict):
|
|
21
21
|
"""
|
|
@@ -262,12 +262,15 @@ class AmoCRMClient:
|
|
|
262
262
|
либо полностью отключить логирование, установив disable_logging=True.
|
|
263
263
|
"""
|
|
264
264
|
def __init__(
|
|
265
|
-
self,
|
|
266
|
-
base_url,
|
|
267
|
-
token_file=None,
|
|
268
|
-
cache_config=None,
|
|
269
|
-
log_level=logging.INFO,
|
|
265
|
+
self,
|
|
266
|
+
base_url,
|
|
267
|
+
token_file=None,
|
|
268
|
+
cache_config=None,
|
|
269
|
+
log_level=logging.INFO,
|
|
270
270
|
disable_logging=False,
|
|
271
|
+
rate_limit: Optional[int] = None,
|
|
272
|
+
max_retries: int = 3,
|
|
273
|
+
retry_delay: float = 1.0,
|
|
271
274
|
*,
|
|
272
275
|
client_id: Optional[str] = None,
|
|
273
276
|
client_secret: Optional[str] = None,
|
|
@@ -275,14 +278,21 @@ class AmoCRMClient:
|
|
|
275
278
|
):
|
|
276
279
|
"""
|
|
277
280
|
Инициализирует клиента, задавая базовый URL, токен авторизации и настройки кэша для кастомных полей.
|
|
278
|
-
|
|
281
|
+
|
|
279
282
|
:param base_url: Базовый URL API amoCRM.
|
|
280
283
|
:param token_file: Файл, содержащий токен авторизации.
|
|
281
284
|
:param cache_config: Конфигурация кэширования (объект CacheConfig или None для значений по умолчанию)
|
|
282
285
|
:param log_level: Уровень логирования (например, logging.DEBUG, logging.INFO).
|
|
283
286
|
:param disable_logging: Если True, логирование будет отключено.
|
|
287
|
+
:param rate_limit: Максимальное количество запросов в секунду (по умолчанию 7).
|
|
288
|
+
:param max_retries: Максимальное количество повторных попыток при ошибках (по умолчанию 3).
|
|
289
|
+
:param retry_delay: Базовая задержка между попытками в секундах (по умолчанию 1.0).
|
|
284
290
|
"""
|
|
285
291
|
self.base_url = base_url.rstrip('/')
|
|
292
|
+
self.rate_limit = rate_limit if rate_limit is not None else DEFAULT_RATE_LIMIT
|
|
293
|
+
self.max_retries = max_retries
|
|
294
|
+
self.retry_delay = retry_delay
|
|
295
|
+
self._request_times: List[float] = [] # Для отслеживания времени запросов
|
|
286
296
|
domain = self.base_url.split("//")[-1].split(".")[0]
|
|
287
297
|
self.domain = domain
|
|
288
298
|
self.token_file = token_file or os.path.join(os.path.expanduser('~'), '.amocrm_token.json')
|
|
@@ -378,11 +388,27 @@ class AmoCRMClient:
|
|
|
378
388
|
|
|
379
389
|
raise Exception("Токен истёк или некорректен, и нет данных для refresh_token. Обновите токен.")
|
|
380
390
|
|
|
381
|
-
|
|
382
|
-
|
|
391
|
+
def _wait_for_rate_limit(self):
|
|
392
|
+
"""Ожидает, если превышен лимит запросов в секунду."""
|
|
393
|
+
now = time.time()
|
|
394
|
+
# Удаляем запросы старше 1 секунды
|
|
395
|
+
self._request_times = [t for t in self._request_times if now - t < 1.0]
|
|
396
|
+
|
|
397
|
+
if len(self._request_times) >= self.rate_limit:
|
|
398
|
+
# Ждём до освобождения слота
|
|
399
|
+
sleep_time = 1.0 - (now - self._request_times[0])
|
|
400
|
+
if sleep_time > 0:
|
|
401
|
+
self.logger.debug(f"Rate limit: ожидание {sleep_time:.3f}с (лимит {self.rate_limit} req/s)")
|
|
402
|
+
time.sleep(sleep_time)
|
|
403
|
+
# Очищаем старые записи после ожидания
|
|
404
|
+
now = time.time()
|
|
405
|
+
self._request_times = [t for t in self._request_times if now - t < 1.0]
|
|
406
|
+
|
|
407
|
+
self._request_times.append(time.time())
|
|
408
|
+
|
|
383
409
|
def _make_request(self, method, endpoint, params=None, data=None, timeout=10):
|
|
384
410
|
"""
|
|
385
|
-
Выполняет HTTP-запрос к API amoCRM с учетом ограничения по скорости (rate limit).
|
|
411
|
+
Выполняет HTTP-запрос к API amoCRM с учетом ограничения по скорости (rate limit) и retry.
|
|
386
412
|
|
|
387
413
|
:param method: HTTP-метод (GET, PATCH, POST, DELETE и т.д.).
|
|
388
414
|
:param endpoint: Конечная точка API (начинается с /api/v4/).
|
|
@@ -390,28 +416,88 @@ class AmoCRMClient:
|
|
|
390
416
|
:param data: Данные, отправляемые в JSON-формате.
|
|
391
417
|
:param timeout: Тайм‑аут запроса в секундах (по умолчанию 10).
|
|
392
418
|
:return: Ответ в формате JSON или None (если статус 204).
|
|
393
|
-
:raises Exception: При получении кода ошибки, отличного от 200/204.
|
|
419
|
+
:raises Exception: При получении кода ошибки, отличного от 200/204 после всех retry.
|
|
394
420
|
"""
|
|
395
421
|
url = f"{self.base_url}{endpoint}"
|
|
396
422
|
headers = {
|
|
397
423
|
"Authorization": f"Bearer {self.token}",
|
|
398
424
|
"Content-Type": "application/json"
|
|
399
425
|
}
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
426
|
+
|
|
427
|
+
last_exception = None
|
|
428
|
+
retryable_status_codes = {429, 500, 502, 503, 504}
|
|
429
|
+
|
|
430
|
+
for attempt in range(self.max_retries + 1):
|
|
431
|
+
# Ручной rate limiting перед каждой попыткой
|
|
432
|
+
self._wait_for_rate_limit()
|
|
433
|
+
|
|
434
|
+
try:
|
|
435
|
+
self.logger.debug(f"Making {method} request to {url} (attempt {attempt + 1})")
|
|
436
|
+
response = requests.request(method, url, headers=headers, params=params, json=data, timeout=timeout)
|
|
437
|
+
|
|
438
|
+
# 401 - пробуем refresh токена (один раз)
|
|
439
|
+
if response.status_code == 401 and self.refresh_token and attempt == 0:
|
|
440
|
+
self.logger.info("Получен 401, пробую обновить токен и повторить запрос…")
|
|
441
|
+
self._refresh_access_token()
|
|
442
|
+
headers["Authorization"] = f"Bearer {self.token}"
|
|
443
|
+
continue
|
|
444
|
+
|
|
445
|
+
# Успех
|
|
446
|
+
if response.status_code in (200, 204):
|
|
447
|
+
if response.status_code == 204:
|
|
448
|
+
return None
|
|
449
|
+
return response.json()
|
|
450
|
+
|
|
451
|
+
# Retryable ошибки (429, 5xx)
|
|
452
|
+
if response.status_code in retryable_status_codes:
|
|
453
|
+
delay = self._calculate_retry_delay(attempt, response)
|
|
454
|
+
self.logger.warning(
|
|
455
|
+
f"Получен {response.status_code}, retry через {delay:.1f}с (попытка {attempt + 1}/{self.max_retries + 1})"
|
|
456
|
+
)
|
|
457
|
+
last_exception = Exception(f"Ошибка запроса: {response.status_code}, {response.text}")
|
|
458
|
+
if attempt < self.max_retries:
|
|
459
|
+
time.sleep(delay)
|
|
460
|
+
continue
|
|
461
|
+
raise last_exception
|
|
462
|
+
|
|
463
|
+
# Не-retryable ошибка (400, 403, 404 и т.д.)
|
|
464
|
+
self.logger.error(f"Request error {response.status_code}: {response.text}")
|
|
465
|
+
raise Exception(f"Ошибка запроса: {response.status_code}, {response.text}")
|
|
466
|
+
|
|
467
|
+
except requests.exceptions.Timeout as e:
|
|
468
|
+
last_exception = e
|
|
469
|
+
delay = self._calculate_retry_delay(attempt)
|
|
470
|
+
self.logger.warning(f"Timeout, retry через {delay:.1f}с (попытка {attempt + 1}/{self.max_retries + 1})")
|
|
471
|
+
if attempt < self.max_retries:
|
|
472
|
+
time.sleep(delay)
|
|
473
|
+
continue
|
|
474
|
+
raise Exception(f"Timeout после {self.max_retries + 1} попыток: {e}")
|
|
475
|
+
|
|
476
|
+
except requests.exceptions.ConnectionError as e:
|
|
477
|
+
last_exception = e
|
|
478
|
+
delay = self._calculate_retry_delay(attempt)
|
|
479
|
+
self.logger.warning(f"Connection error, retry через {delay:.1f}с (попытка {attempt + 1}/{self.max_retries + 1})")
|
|
480
|
+
if attempt < self.max_retries:
|
|
481
|
+
time.sleep(delay)
|
|
482
|
+
continue
|
|
483
|
+
raise Exception(f"Connection error после {self.max_retries + 1} попыток: {e}")
|
|
484
|
+
|
|
485
|
+
# Если дошли сюда — исчерпали все попытки
|
|
486
|
+
raise last_exception or Exception("Неизвестная ошибка после всех retry")
|
|
487
|
+
|
|
488
|
+
def _calculate_retry_delay(self, attempt: int, response=None) -> float:
|
|
489
|
+
"""Вычисляет задержку перед retry с exponential backoff."""
|
|
490
|
+
# Проверяем Retry-After header (для 429)
|
|
491
|
+
if response is not None:
|
|
492
|
+
retry_after = response.headers.get("Retry-After")
|
|
493
|
+
if retry_after:
|
|
494
|
+
try:
|
|
495
|
+
return float(retry_after)
|
|
496
|
+
except ValueError:
|
|
497
|
+
pass
|
|
498
|
+
|
|
499
|
+
# Exponential backoff: delay * 2^attempt (1, 2, 4, 8...)
|
|
500
|
+
return self.retry_delay * (2 ** attempt)
|
|
415
501
|
|
|
416
502
|
def _refresh_access_token(self):
|
|
417
503
|
"""Обновляет access_token по refresh_token и сохраняет его в token_file."""
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: amochka
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.3
|
|
4
4
|
Summary: Python library for working with amoCRM API with ETL capabilities
|
|
5
5
|
Author-email: Timur <timurdt@gmail.com>
|
|
6
6
|
License: MIT
|
|
@@ -26,7 +26,6 @@ Classifier: Topic :: Internet :: WWW/HTTP
|
|
|
26
26
|
Requires-Python: >=3.6
|
|
27
27
|
Description-Content-Type: text/markdown
|
|
28
28
|
Requires-Dist: requests>=2.25.0
|
|
29
|
-
Requires-Dist: ratelimit>=2.2.0
|
|
30
29
|
Requires-Dist: psycopg2-binary>=2.9.0
|
|
31
30
|
Requires-Dist: python-dotenv>=1.0.0
|
|
32
31
|
|
|
@@ -107,6 +107,7 @@ class AmoCRMAccount:
|
|
|
107
107
|
mybi_account_id: int # Внутренний account_id как в mybi.ru (для совместимости)
|
|
108
108
|
pipeline_ids: Optional[List[int]] = None # None = все воронки
|
|
109
109
|
cache_dir: Optional[Path] = None
|
|
110
|
+
rate_limit: int = 7 # Максимум запросов в секунду (по умолчанию 7)
|
|
110
111
|
|
|
111
112
|
def __post_init__(self):
|
|
112
113
|
if isinstance(self.token_path, str):
|
|
@@ -125,6 +126,7 @@ class AmoCRMAccount:
|
|
|
125
126
|
mybi_account_id=int(d.get("mybi_account_id", 0)),
|
|
126
127
|
pipeline_ids=d.get("pipeline_ids"),
|
|
127
128
|
cache_dir=Path(d.get("cache_dir", ".cache")) if d.get("cache_dir") else None,
|
|
129
|
+
rate_limit=int(d.get("rate_limit", 7)),
|
|
128
130
|
)
|
|
129
131
|
|
|
130
132
|
|
|
@@ -234,19 +234,23 @@ class PostgresLoader:
|
|
|
234
234
|
(account_id, leads_id),
|
|
235
235
|
)
|
|
236
236
|
|
|
237
|
-
# Вставляем новые
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
237
|
+
# Вставляем новые (batch insert)
|
|
238
|
+
if not attributes:
|
|
239
|
+
return 0
|
|
240
|
+
|
|
241
|
+
values = [
|
|
242
|
+
(account_id, leads_id, attr["attribute_id"], attr["name"], attr.get("value"))
|
|
243
|
+
for attr in attributes
|
|
244
|
+
]
|
|
245
|
+
cursor.executemany(
|
|
246
|
+
"""
|
|
247
|
+
INSERT INTO amocrm_leads_attributes (account_id, leads_id, attribute_id, name, value)
|
|
248
|
+
VALUES (%s, %s, %s, %s, %s)
|
|
249
|
+
""",
|
|
250
|
+
values,
|
|
251
|
+
)
|
|
248
252
|
|
|
249
|
-
return
|
|
253
|
+
return len(values)
|
|
250
254
|
|
|
251
255
|
def upsert_lead_tags(self, cursor, tags: List[Dict[str, Any]], leads_id: int) -> int:
|
|
252
256
|
"""Вставляет теги сделки (полная замена)."""
|
|
@@ -261,19 +265,20 @@ class PostgresLoader:
|
|
|
261
265
|
(account_id, leads_id),
|
|
262
266
|
)
|
|
263
267
|
|
|
264
|
-
# Вставляем новые
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
268
|
+
# Вставляем новые (batch insert)
|
|
269
|
+
values = [
|
|
270
|
+
(account_id, leads_id, tag["tag_id"], tag["name"])
|
|
271
|
+
for tag in tags
|
|
272
|
+
]
|
|
273
|
+
cursor.executemany(
|
|
274
|
+
"""
|
|
275
|
+
INSERT INTO amocrm_leads_tags (account_id, leads_id, tag_id, name)
|
|
276
|
+
VALUES (%s, %s, %s, %s)
|
|
277
|
+
""",
|
|
278
|
+
values,
|
|
279
|
+
)
|
|
275
280
|
|
|
276
|
-
return
|
|
281
|
+
return len(values)
|
|
277
282
|
|
|
278
283
|
def upsert_lead_contacts(self, cursor, contacts: List[Dict[str, Any]], leads_id: int) -> int:
|
|
279
284
|
"""Вставляет связи сделки с контактами (полная замена)."""
|
|
@@ -288,19 +293,20 @@ class PostgresLoader:
|
|
|
288
293
|
(account_id, leads_id),
|
|
289
294
|
)
|
|
290
295
|
|
|
291
|
-
# Вставляем новые
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
296
|
+
# Вставляем новые (batch insert)
|
|
297
|
+
values = [
|
|
298
|
+
(account_id, leads_id, contact["contacts_id"], contact.get("main", False))
|
|
299
|
+
for contact in contacts
|
|
300
|
+
]
|
|
301
|
+
cursor.executemany(
|
|
302
|
+
"""
|
|
303
|
+
INSERT INTO amocrm_leads_contacts (account_id, leads_id, contacts_id, main)
|
|
304
|
+
VALUES (%s, %s, %s, %s)
|
|
305
|
+
""",
|
|
306
|
+
values,
|
|
307
|
+
)
|
|
302
308
|
|
|
303
|
-
return
|
|
309
|
+
return len(values)
|
|
304
310
|
|
|
305
311
|
def load_transformed_lead(
|
|
306
312
|
self,
|
|
@@ -441,26 +447,24 @@ class PostgresLoader:
|
|
|
441
447
|
),
|
|
442
448
|
)
|
|
443
449
|
|
|
444
|
-
# Атрибуты контакта
|
|
450
|
+
# Атрибуты контакта (batch insert)
|
|
445
451
|
if transformed.attributes:
|
|
452
|
+
account_id = transformed.contact["account_id"]
|
|
446
453
|
cursor.execute(
|
|
447
454
|
"DELETE FROM amocrm_contacts_attributes WHERE account_id = %s AND contacts_id = %s",
|
|
448
|
-
(
|
|
455
|
+
(account_id, contacts_id),
|
|
456
|
+
)
|
|
457
|
+
values = [
|
|
458
|
+
(account_id, contacts_id, attr["attribute_id"], attr["name"], attr.get("value"))
|
|
459
|
+
for attr in transformed.attributes
|
|
460
|
+
]
|
|
461
|
+
cursor.executemany(
|
|
462
|
+
"""
|
|
463
|
+
INSERT INTO amocrm_contacts_attributes (account_id, contacts_id, attribute_id, name, value)
|
|
464
|
+
VALUES (%s, %s, %s, %s, %s)
|
|
465
|
+
""",
|
|
466
|
+
values,
|
|
449
467
|
)
|
|
450
|
-
for attr in transformed.attributes:
|
|
451
|
-
cursor.execute(
|
|
452
|
-
"""
|
|
453
|
-
INSERT INTO amocrm_contacts_attributes (account_id, contacts_id, attribute_id, name, value)
|
|
454
|
-
VALUES (%s, %s, %s, %s, %s)
|
|
455
|
-
""",
|
|
456
|
-
(
|
|
457
|
-
transformed.contact["account_id"],
|
|
458
|
-
contacts_id,
|
|
459
|
-
attr["attribute_id"],
|
|
460
|
-
attr["name"],
|
|
461
|
-
attr.get("value"),
|
|
462
|
-
),
|
|
463
|
-
)
|
|
464
468
|
|
|
465
469
|
return contacts_id
|
|
466
470
|
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "amochka"
|
|
7
|
-
version = "0.3.
|
|
7
|
+
version = "0.3.3"
|
|
8
8
|
description = "Python library for working with amoCRM API with ETL capabilities"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
authors = [
|
|
@@ -31,7 +31,6 @@ keywords = ["amocrm", "crm", "api", "client", "automation", "etl"]
|
|
|
31
31
|
requires-python = ">=3.6"
|
|
32
32
|
dependencies = [
|
|
33
33
|
"requests>=2.25.0",
|
|
34
|
-
"ratelimit>=2.2.0",
|
|
35
34
|
"psycopg2-binary>=2.9.0",
|
|
36
35
|
"python-dotenv>=1.0.0"
|
|
37
36
|
]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|