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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: amochka
3
- Version: 0.3.1
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
- from ratelimit import limits, sleep_and_retry
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
- RATE_LIMIT = 7 # Максимум 7 запросов в секунду
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
- @sleep_and_retry
382
- @limits(calls=RATE_LIMIT, period=1)
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
- self.logger.debug(f"Making {method} request to {url} with params {params} and data {data}")
401
- response = requests.request(method, url, headers=headers, params=params, json=data, timeout=timeout)
402
-
403
- if response.status_code == 401 and self.refresh_token:
404
- self.logger.info("Получен 401, пробую обновить токен и повторить запрос…")
405
- self._refresh_access_token()
406
- headers["Authorization"] = f"Bearer {self.token}"
407
- response = requests.request(method, url, headers=headers, params=params, json=data, timeout=timeout)
408
-
409
- if response.status_code not in (200, 204):
410
- self.logger.error(f"Request error {response.status_code}: {response.text}")
411
- raise Exception(f"Ошибка запроса: {response.status_code}, {response.text}")
412
- if response.status_code == 204:
413
- return None
414
- return response.json()
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.1
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
 
@@ -1,4 +1,3 @@
1
1
  requests>=2.25.0
2
- ratelimit>=2.2.0
3
2
  psycopg2-binary>=2.9.0
4
3
  python-dotenv>=1.0.0
@@ -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
 
@@ -48,6 +48,7 @@ class AmoCRMExtractor:
48
48
  base_url=self.account.base_url,
49
49
  token_file=str(self.account.token_path),
50
50
  cache_config=cache_config,
51
+ rate_limit=self.account.rate_limit,
51
52
  )
52
53
 
53
54
  @property
@@ -234,19 +234,23 @@ class PostgresLoader:
234
234
  (account_id, leads_id),
235
235
  )
236
236
 
237
- # Вставляем новые
238
- inserted = 0
239
- for attr in attributes:
240
- cursor.execute(
241
- """
242
- INSERT INTO amocrm_leads_attributes (account_id, leads_id, attribute_id, name, value)
243
- VALUES (%s, %s, %s, %s, %s)
244
- """,
245
- (account_id, leads_id, attr["attribute_id"], attr["name"], attr.get("value")),
246
- )
247
- inserted += 1
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 inserted
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
- inserted = 0
266
- for tag in tags:
267
- cursor.execute(
268
- """
269
- INSERT INTO amocrm_leads_tags (account_id, leads_id, tag_id, name)
270
- VALUES (%s, %s, %s, %s)
271
- """,
272
- (account_id, leads_id, tag["tag_id"], tag["name"]),
273
- )
274
- inserted += 1
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 inserted
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
- inserted = 0
293
- for contact in contacts:
294
- cursor.execute(
295
- """
296
- INSERT INTO amocrm_leads_contacts (account_id, leads_id, contacts_id, main)
297
- VALUES (%s, %s, %s, %s)
298
- """,
299
- (account_id, leads_id, contact["contacts_id"], contact.get("main", False)),
300
- )
301
- inserted += 1
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 inserted
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
- (transformed.contact["account_id"], contacts_id),
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.1"
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