amochka 0.3.2__tar.gz → 0.3.4__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.2
3
+ Version: 0.3.4
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
@@ -269,6 +269,8 @@ class AmoCRMClient:
269
269
  log_level=logging.INFO,
270
270
  disable_logging=False,
271
271
  rate_limit: Optional[int] = None,
272
+ max_retries: int = 3,
273
+ retry_delay: float = 1.0,
272
274
  *,
273
275
  client_id: Optional[str] = None,
274
276
  client_secret: Optional[str] = None,
@@ -283,9 +285,13 @@ class AmoCRMClient:
283
285
  :param log_level: Уровень логирования (например, logging.DEBUG, logging.INFO).
284
286
  :param disable_logging: Если True, логирование будет отключено.
285
287
  :param rate_limit: Максимальное количество запросов в секунду (по умолчанию 7).
288
+ :param max_retries: Максимальное количество повторных попыток при ошибках (по умолчанию 3).
289
+ :param retry_delay: Базовая задержка между попытками в секундах (по умолчанию 1.0).
286
290
  """
287
291
  self.base_url = base_url.rstrip('/')
288
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
289
295
  self._request_times: List[float] = [] # Для отслеживания времени запросов
290
296
  domain = self.base_url.split("//")[-1].split(".")[0]
291
297
  self.domain = domain
@@ -402,7 +408,7 @@ class AmoCRMClient:
402
408
 
403
409
  def _make_request(self, method, endpoint, params=None, data=None, timeout=10):
404
410
  """
405
- Выполняет HTTP-запрос к API amoCRM с учетом ограничения по скорости (rate limit).
411
+ Выполняет HTTP-запрос к API amoCRM с учетом ограничения по скорости (rate limit) и retry.
406
412
 
407
413
  :param method: HTTP-метод (GET, PATCH, POST, DELETE и т.д.).
408
414
  :param endpoint: Конечная точка API (начинается с /api/v4/).
@@ -410,31 +416,88 @@ class AmoCRMClient:
410
416
  :param data: Данные, отправляемые в JSON-формате.
411
417
  :param timeout: Тайм‑аут запроса в секундах (по умолчанию 10).
412
418
  :return: Ответ в формате JSON или None (если статус 204).
413
- :raises Exception: При получении кода ошибки, отличного от 200/204.
419
+ :raises Exception: При получении кода ошибки, отличного от 200/204 после всех retry.
414
420
  """
415
- # Ручной rate limiting
416
- self._wait_for_rate_limit()
417
-
418
421
  url = f"{self.base_url}{endpoint}"
419
422
  headers = {
420
423
  "Authorization": f"Bearer {self.token}",
421
424
  "Content-Type": "application/json"
422
425
  }
423
- self.logger.debug(f"Making {method} request to {url} with params {params} and data {data}")
424
- response = requests.request(method, url, headers=headers, params=params, json=data, timeout=timeout)
425
-
426
- if response.status_code == 401 and self.refresh_token:
427
- self.logger.info("Получен 401, пробую обновить токен и повторить запрос…")
428
- self._refresh_access_token()
429
- headers["Authorization"] = f"Bearer {self.token}"
430
- response = requests.request(method, url, headers=headers, params=params, json=data, timeout=timeout)
431
-
432
- if response.status_code not in (200, 204):
433
- self.logger.error(f"Request error {response.status_code}: {response.text}")
434
- raise Exception(f"Ошибка запроса: {response.status_code}, {response.text}")
435
- if response.status_code == 204:
436
- return None
437
- 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)
438
501
 
439
502
  def _refresh_access_token(self):
440
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.2
3
+ Version: 0.3.4
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
@@ -206,10 +206,10 @@ class AmoCRMExtractor:
206
206
 
207
207
  count = 0
208
208
  for event in self.client.iter_events(
209
- entity_type=entity_type,
209
+ entity=entity_type, # client.py использует 'entity', не 'entity_type'
210
210
  event_type=event_types[0] if event_types and len(event_types) == 1 else None,
211
- start=created_from,
212
- end=created_to,
211
+ created_from=created_from,
212
+ created_to=created_to,
213
213
  ):
214
214
  # Фильтруем по типам если указано несколько
215
215
  if event_types and len(event_types) > 1:
@@ -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
 
@@ -199,6 +199,8 @@ def mark_deleted_leads(
199
199
  loader: PostgresLoader,
200
200
  mybi_account_id: int,
201
201
  pipeline_ids: Optional[List[int]] = None,
202
+ updated_from: Optional[datetime] = None,
203
+ updated_to: Optional[datetime] = None,
202
204
  ) -> int:
203
205
  """
204
206
  Помечает удалённые сделки в БД (is_deleted = true).
@@ -206,12 +208,18 @@ def mark_deleted_leads(
206
208
  Выгружает список удалённых сделок из amoCRM (корзина) и обновляет
207
209
  соответствующие записи в БД.
208
210
 
211
+ Args:
212
+ updated_from: Начало периода (фильтр по deleted_at)
213
+ updated_to: Конец периода (фильтр по deleted_at)
214
+
209
215
  Returns:
210
216
  Количество помеченных сделок
211
217
  """
212
218
  # Выгружаем ID удалённых сделок из amoCRM
213
219
  deleted_lead_ids = []
214
220
  for lead in extractor.iter_leads(
221
+ updated_from=updated_from,
222
+ updated_to=updated_to,
215
223
  pipeline_ids=pipeline_ids,
216
224
  only_deleted=True,
217
225
  ):
@@ -538,6 +546,8 @@ def run_etl_for_account(
538
546
  loader,
539
547
  mybi_id,
540
548
  pipeline_ids=account.pipeline_ids,
549
+ updated_from=leads_updated_from,
550
+ updated_to=updated_to,
541
551
  )
542
552
  stats["leads_marked_deleted"] = deleted_count
543
553
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "amochka"
7
- version = "0.3.2"
7
+ version = "0.3.4"
8
8
  description = "Python library for working with amoCRM API with ETL capabilities"
9
9
  readme = "README.md"
10
10
  authors = [
@@ -22,9 +22,11 @@ from amochka import (
22
22
 
23
23
  class DummyClient(AmoCRMClient):
24
24
  def __init__(self):
25
+ import time
26
+ # expires_at как числовой timestamp (через 1 час)
25
27
  token_data = {
26
28
  "access_token": "x",
27
- "expires_at": str(int(datetime.utcnow().timestamp()) + 3600)
29
+ "expires_at": str(time.time() + 3600)
28
30
  }
29
31
  super().__init__(
30
32
  base_url="https://example.com",
@@ -249,7 +251,8 @@ def test_export_helpers_write_expected_files(tmp_path):
249
251
  assert pipelines_lines[0]["entity"] == "pipeline"
250
252
 
251
253
  contacts_call = next(item for item in client.calls if item[1] == "/api/v4/contacts")
252
- assert contacts_call[2]["filter[id]"] == "11,12"
254
+ # API использует filter[id][] для массивов
255
+ assert contacts_call[2].get("filter[id][]") == ["11", "12"] or contacts_call[2].get("filter[id]") == "11,12"
253
256
  assert "filter[updated_at][from]" not in contacts_call[2]
254
257
 
255
258
  events_call = next(item for item in client.calls if item[1] == "/api/v4/events")
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes