amochka 0.3.4__tar.gz → 0.4.0__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.4
3
+ Version: 0.4.0
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
@@ -2,9 +2,17 @@
2
2
  amochka: Библиотека для работы с API amoCRM.
3
3
  """
4
4
 
5
- __version__ = "0.2.0"
5
+ __version__ = "0.4.0"
6
6
 
7
7
  from .client import AmoCRMClient, CacheConfig
8
+ from .errors import (
9
+ AmoCRMError,
10
+ APIError,
11
+ AuthenticationError,
12
+ NotFoundError,
13
+ RateLimitError,
14
+ ValidationError,
15
+ )
8
16
  from .etl import (
9
17
  write_ndjson,
10
18
  export_leads_to_ndjson,
@@ -18,6 +26,14 @@ from .etl import (
18
26
  __all__ = [
19
27
  "AmoCRMClient",
20
28
  "CacheConfig",
29
+ # Exceptions
30
+ "AmoCRMError",
31
+ "APIError",
32
+ "AuthenticationError",
33
+ "NotFoundError",
34
+ "RateLimitError",
35
+ "ValidationError",
36
+ # ETL functions
21
37
  "write_ndjson",
22
38
  "export_leads_to_ndjson",
23
39
  "export_contacts_to_ndjson",
@@ -1,12 +1,22 @@
1
1
  import os
2
+ import stat
2
3
  import time
3
4
  import json
4
5
  import requests
5
6
  import logging
6
7
  from datetime import datetime
7
- from typing import Iterator, List, Optional, Sequence, Union
8
+ from typing import Any, Callable, Iterator, List, Optional, Sequence, Union
8
9
  # ratelimit больше не используется - rate limiting реализован вручную через self.rate_limit
9
10
 
11
+ from .errors import (
12
+ AmoCRMError,
13
+ APIError,
14
+ AuthenticationError,
15
+ NotFoundError,
16
+ RateLimitError,
17
+ ValidationError,
18
+ )
19
+
10
20
  # Создаём базовый логгер
11
21
  logger = logging.getLogger(__name__)
12
22
  if not logger.handlers:
@@ -194,8 +204,18 @@ class CacheConfig:
194
204
  def __init__(self, enabled=True, storage='file', base_dir=None, file=None, lifetime_hours='default'):
195
205
  self.enabled = enabled
196
206
  self.storage = storage.lower()
197
- self.base_dir = base_dir or os.path.join(os.path.expanduser('~'), '.amocrm', 'cache')
198
- self.file = file # Для обратной совместимости с custom fields
207
+
208
+ # Валидация и нормализация base_dir для защиты от path traversal
209
+ if base_dir is None:
210
+ self.base_dir = os.path.join(os.path.expanduser('~'), '.amocrm', 'cache')
211
+ else:
212
+ self.base_dir = self._validate_path(base_dir, "base_dir")
213
+
214
+ # Валидация file для обратной совместимости
215
+ if file is not None:
216
+ self.file = self._validate_path(file, "file")
217
+ else:
218
+ self.file = None
199
219
 
200
220
  # Обработка lifetime_hours: может быть int, dict, None, или 'default'
201
221
  if lifetime_hours == 'default':
@@ -218,6 +238,29 @@ class CacheConfig:
218
238
  # Fallback на дефолтные значения
219
239
  self.lifetime_hours = self.DEFAULT_LIFETIMES.copy()
220
240
 
241
+ @staticmethod
242
+ def _validate_path(path: str, param_name: str) -> str:
243
+ """
244
+ Валидирует путь для защиты от path traversal атак.
245
+
246
+ :param path: Путь для валидации
247
+ :param param_name: Название параметра (для сообщения об ошибке)
248
+ :return: Нормализованный абсолютный путь
249
+ :raises ValueError: Если путь содержит path traversal символы
250
+ """
251
+ if not path or not isinstance(path, str):
252
+ raise ValueError(f"{param_name} must be a non-empty string")
253
+
254
+ # Разворачиваем ~ и нормализуем путь
255
+ normalized = os.path.normpath(os.path.expanduser(path))
256
+
257
+ # Проверяем на наличие ".." в нормализованном пути
258
+ # (это означает попытку выйти за пределы директории)
259
+ if ".." in normalized.split(os.sep):
260
+ raise ValueError(f"{param_name} contains path traversal sequences: {path}")
261
+
262
+ return normalized
263
+
221
264
  def get_lifetime(self, data_type):
222
265
  """
223
266
  Получает время жизни кэша для указанного типа данных.
@@ -288,7 +331,8 @@ class AmoCRMClient:
288
331
  :param max_retries: Максимальное количество повторных попыток при ошибках (по умолчанию 3).
289
332
  :param retry_delay: Базовая задержка между попытками в секундах (по умолчанию 1.0).
290
333
  """
291
- self.base_url = base_url.rstrip('/')
334
+ # Валидация base_url для защиты от SSRF
335
+ self.base_url = self._validate_base_url(base_url)
292
336
  self.rate_limit = rate_limit if rate_limit is not None else DEFAULT_RATE_LIMIT
293
337
  self.max_retries = max_retries
294
338
  self.retry_delay = retry_delay
@@ -339,16 +383,101 @@ class AmoCRMClient:
339
383
  self._pipelines_cache = None
340
384
  self._users_cache = None
341
385
 
386
+ # Memory cache TTL (время жизни кэша в памяти в секундах, по умолчанию 1 час)
387
+ self._memory_cache_ttl = 3600
388
+ self._cache_timestamps = {}
389
+
390
+ @staticmethod
391
+ def _mask_sensitive(value: str, show_chars: int = 4) -> str:
392
+ """
393
+ Маскирует sensitive данные для безопасного логирования.
394
+
395
+ :param value: Строка для маскирования
396
+ :param show_chars: Количество символов для показа в начале и конце
397
+ :return: Замаскированная строка
398
+ """
399
+ if not value or not isinstance(value, str):
400
+ return "***"
401
+ if len(value) <= show_chars * 2:
402
+ return "***"
403
+ return f"{value[:show_chars]}...{value[-show_chars:]}"
404
+
405
+ @staticmethod
406
+ def _validate_base_url(base_url: str) -> str:
407
+ """
408
+ Валидирует base_url для защиты от SSRF атак.
409
+
410
+ :param base_url: URL для валидации
411
+ :return: Нормализованный URL (без trailing slash)
412
+ :raises ValueError: Если URL не соответствует разрешенным доменам amoCRM/Kommo
413
+ """
414
+ if not base_url or not isinstance(base_url, str):
415
+ raise ValueError("base_url must be a non-empty string")
416
+
417
+ # Приводим к lowercase для проверки
418
+ url_lower = base_url.lower().rstrip('/')
419
+
420
+ # Проверяем протокол (только HTTPS)
421
+ if not url_lower.startswith('https://'):
422
+ raise ValueError("base_url must use HTTPS protocol for security")
423
+
424
+ # Извлекаем домен
425
+ try:
426
+ # Удаляем протокол и разбиваем на части
427
+ domain_part = url_lower.replace('https://', '')
428
+ domain = domain_part.split('/')[0] # Берем только домен без пути
429
+
430
+ # Разрешенные домены для amoCRM и Kommo
431
+ allowed_domains = [
432
+ '.amocrm.ru',
433
+ '.amocrm.com',
434
+ '.kommo.com',
435
+ ]
436
+
437
+ # Проверяем, что домен заканчивается на один из разрешенных
438
+ is_valid = any(domain.endswith(suffix) for suffix in allowed_domains)
439
+
440
+ if not is_valid:
441
+ raise ValueError(
442
+ f"base_url must be an official amoCRM or Kommo domain "
443
+ f"(*.amocrm.ru, *.amocrm.com, *.kommo.com), got: {domain}"
444
+ )
445
+
446
+ except Exception as e:
447
+ if isinstance(e, ValueError):
448
+ raise
449
+ raise ValueError(f"Invalid base_url format: {base_url}") from e
450
+
451
+ return base_url.rstrip('/')
452
+
342
453
  def load_token(self):
343
454
  """
344
- Загружает токен авторизации из файла или строки, проверяет его срок действия.
455
+ Загружает токен авторизации из environment variables или файла, проверяет его срок действия.
345
456
  При наличии refresh_token и учётных данных пробует обновить токен.
346
457
 
458
+ Приоритет загрузки токенов:
459
+ 1. Environment variables (AMOCRM_ACCESS_TOKEN, AMOCRM_REFRESH_TOKEN, и т.д.)
460
+ 2. Файл token_file или строка JSON
461
+
347
462
  :return: Действительный access_token.
348
- :raises Exception: Если токен не найден или истёк и нет возможности обновить.
463
+ :raises AuthenticationError: Если токен не найден или истёк и нет возможности обновить.
349
464
  """
350
465
  data = None
351
- if os.path.exists(self.token_file):
466
+
467
+ # 1. Проверяем environment variables (приоритет)
468
+ env_access_token = os.environ.get('AMOCRM_ACCESS_TOKEN')
469
+ if env_access_token:
470
+ self.logger.debug("Token loaded from environment variables.")
471
+ data = {
472
+ 'access_token': env_access_token,
473
+ 'refresh_token': os.environ.get('AMOCRM_REFRESH_TOKEN'),
474
+ 'expires_at': os.environ.get('AMOCRM_EXPIRES_AT'),
475
+ 'client_id': os.environ.get('AMOCRM_CLIENT_ID'),
476
+ 'client_secret': os.environ.get('AMOCRM_CLIENT_SECRET'),
477
+ 'redirect_uri': os.environ.get('AMOCRM_REDIRECT_URI'),
478
+ }
479
+ # 2. Загружаем из файла или строки
480
+ elif os.path.exists(self.token_file):
352
481
  with open(self.token_file, 'r') as f:
353
482
  data = json.load(f)
354
483
  self.logger.debug(f"Token loaded from file: {self.token_file}")
@@ -357,7 +486,9 @@ class AmoCRMClient:
357
486
  data = json.loads(self.token_file)
358
487
  self.logger.debug("Token parsed from provided string.")
359
488
  except Exception as e:
360
- raise Exception("Токен не найден и не удалось распарсить переданное содержимое.") from e
489
+ raise AuthenticationError(
490
+ "Токен не найден: ни в environment variables, ни в файле, ни в переданной строке."
491
+ ) from e
361
492
 
362
493
  self.refresh_token = data.get('refresh_token', self.refresh_token)
363
494
  self.client_id = data.get('client_id', self.client_id)
@@ -386,23 +517,25 @@ class AmoCRMClient:
386
517
  self.logger.info("Access token истёк, пробую обновить через refresh_token…")
387
518
  return self._refresh_access_token()
388
519
 
389
- raise Exception("Токен истёк или некорректен, и нет данных для refresh_token. Обновите токен.")
520
+ raise AuthenticationError("Токен истёк или некорректен, и нет данных для refresh_token. Обновите токен.")
390
521
 
391
- def _wait_for_rate_limit(self):
392
- """Ожидает, если превышен лимит запросов в секунду."""
522
+ def _clean_old_requests(self):
523
+ """Удаляет запросы старше 1 секунды из истории запросов."""
393
524
  now = time.time()
394
- # Удаляем запросы старше 1 секунды
395
525
  self._request_times = [t for t in self._request_times if now - t < 1.0]
396
526
 
527
+ def _wait_for_rate_limit(self):
528
+ """Ожидает, если превышен лимит запросов в секунду."""
529
+ self._clean_old_requests()
530
+
397
531
  if len(self._request_times) >= self.rate_limit:
398
532
  # Ждём до освобождения слота
399
- sleep_time = 1.0 - (now - self._request_times[0])
533
+ sleep_time = 1.0 - (time.time() - self._request_times[0])
400
534
  if sleep_time > 0:
401
535
  self.logger.debug(f"Rate limit: ожидание {sleep_time:.3f}с (лимит {self.rate_limit} req/s)")
402
536
  time.sleep(sleep_time)
403
537
  # Очищаем старые записи после ожидания
404
- now = time.time()
405
- self._request_times = [t for t in self._request_times if now - t < 1.0]
538
+ self._clean_old_requests()
406
539
 
407
540
  self._request_times.append(time.time())
408
541
 
@@ -416,9 +549,13 @@ class AmoCRMClient:
416
549
  :param data: Данные, отправляемые в JSON-формате.
417
550
  :param timeout: Тайм‑аут запроса в секундах (по умолчанию 10).
418
551
  :return: Ответ в формате JSON или None (если статус 204).
419
- :raises Exception: При получении кода ошибки, отличного от 200/204 после всех retry.
552
+ :raises APIError: При получении кода ошибки, отличного от 200/204 после всех retry.
553
+ :raises RateLimitError: При превышении лимита запросов (429).
554
+ :raises AuthenticationError: При ошибках авторизации (401).
555
+ :raises NotFoundError: При отсутствии ресурса (404).
420
556
  """
421
557
  url = f"{self.base_url}{endpoint}"
558
+ is_sensitive_endpoint = "oauth" in endpoint.lower() or "token" in endpoint.lower()
422
559
  headers = {
423
560
  "Authorization": f"Bearer {self.token}",
424
561
  "Content-Type": "application/json"
@@ -454,36 +591,55 @@ class AmoCRMClient:
454
591
  self.logger.warning(
455
592
  f"Получен {response.status_code}, retry через {delay:.1f}с (попытка {attempt + 1}/{self.max_retries + 1})"
456
593
  )
457
- last_exception = Exception(f"Ошибка запроса: {response.status_code}, {response.text}")
594
+ if response.status_code == 429:
595
+ message = "Rate limit exceeded"
596
+ if not is_sensitive_endpoint and response.text:
597
+ message = f"Rate limit exceeded: {response.text[:200]}"
598
+ last_exception = RateLimitError(
599
+ message,
600
+ retry_after=response.headers.get("Retry-After")
601
+ )
602
+ else:
603
+ if is_sensitive_endpoint:
604
+ last_exception = APIError(response.status_code, f"Request error at {endpoint}")
605
+ else:
606
+ last_exception = APIError(response.status_code, response.text)
458
607
  if attempt < self.max_retries:
459
608
  time.sleep(delay)
460
609
  continue
461
610
  raise last_exception
462
611
 
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}")
612
+ # Не-retryable ошибки (400, 403, 404 и т.д.)
613
+ if response.status_code == 404:
614
+ self.logger.error(f"Request error {response.status_code} at {endpoint}")
615
+ raise NotFoundError(f"Ресурс не найден: {endpoint}")
616
+ if is_sensitive_endpoint:
617
+ self.logger.error(f"Request error {response.status_code} at {endpoint}")
618
+ raise APIError(response.status_code, f"Request error at {endpoint}")
619
+ response_preview = response.text[:200] if response.text else ""
620
+ self.logger.error(f"Request error {response.status_code}: {response_preview}...")
621
+ raise APIError(response.status_code, response.text)
466
622
 
467
623
  except requests.exceptions.Timeout as e:
468
- last_exception = e
624
+ last_exception = APIError(0, f"Timeout: {e}")
469
625
  delay = self._calculate_retry_delay(attempt)
470
626
  self.logger.warning(f"Timeout, retry через {delay:.1f}с (попытка {attempt + 1}/{self.max_retries + 1})")
471
627
  if attempt < self.max_retries:
472
628
  time.sleep(delay)
473
629
  continue
474
- raise Exception(f"Timeout после {self.max_retries + 1} попыток: {e}")
630
+ raise APIError(0, f"Timeout после {self.max_retries + 1} попыток: {e}")
475
631
 
476
632
  except requests.exceptions.ConnectionError as e:
477
- last_exception = e
633
+ last_exception = APIError(0, f"Connection error: {e}")
478
634
  delay = self._calculate_retry_delay(attempt)
479
635
  self.logger.warning(f"Connection error, retry через {delay:.1f}с (попытка {attempt + 1}/{self.max_retries + 1})")
480
636
  if attempt < self.max_retries:
481
637
  time.sleep(delay)
482
638
  continue
483
- raise Exception(f"Connection error после {self.max_retries + 1} попыток: {e}")
639
+ raise APIError(0, f"Connection error после {self.max_retries + 1} попыток: {e}")
484
640
 
485
641
  # Если дошли сюда — исчерпали все попытки
486
- raise last_exception or Exception("Неизвестная ошибка после всех retry")
642
+ raise last_exception or APIError(0, "Неизвестная ошибка после всех retry")
487
643
 
488
644
  def _calculate_retry_delay(self, attempt: int, response=None) -> float:
489
645
  """Вычисляет задержку перед retry с exponential backoff."""
@@ -502,7 +658,7 @@ class AmoCRMClient:
502
658
  def _refresh_access_token(self):
503
659
  """Обновляет access_token по refresh_token и сохраняет его в token_file."""
504
660
  if not all([self.refresh_token, self.client_id, self.client_secret, self.redirect_uri]):
505
- raise Exception("Нельзя обновить токен: отсутствует refresh_token или client_id/client_secret/redirect_uri")
661
+ raise AuthenticationError("Нельзя обновить токен: отсутствует refresh_token или client_id/client_secret/redirect_uri")
506
662
 
507
663
  payload = {
508
664
  "client_id": self.client_id,
@@ -515,15 +671,16 @@ class AmoCRMClient:
515
671
  self.logger.debug(f"Refreshing token via {token_url}")
516
672
  resp = requests.post(token_url, json=payload, timeout=10)
517
673
  if resp.status_code != 200:
518
- self.logger.error(f"Не удалось обновить токен: {resp.status_code} {resp.text}")
519
- raise Exception(f"Не удалось обновить токен: {resp.status_code}")
674
+ # Не логируем response body, так как он может содержать токены
675
+ self.logger.error(f"Не удалось обновить токен: {resp.status_code}")
676
+ raise AuthenticationError(f"Не удалось обновить токен: {resp.status_code}")
520
677
 
521
678
  data = resp.json() or {}
522
679
  access_token = data.get("access_token")
523
680
  refresh_token = data.get("refresh_token", self.refresh_token)
524
681
  expires_in = data.get("expires_in")
525
682
  if not access_token:
526
- raise Exception("Ответ на refresh не содержит access_token")
683
+ raise AuthenticationError("Ответ на refresh не содержит access_token")
527
684
 
528
685
  expires_at = None
529
686
  if expires_in:
@@ -544,7 +701,9 @@ class AmoCRMClient:
544
701
  "client_secret": self.client_secret,
545
702
  "redirect_uri": self.redirect_uri,
546
703
  }, f)
547
- self.logger.debug(f"Новый токен сохранён в {self.token_file}")
704
+ # Устанавливаем права доступа 600 (только владелец может читать/писать)
705
+ os.chmod(self.token_file, stat.S_IRUSR | stat.S_IWUSR)
706
+ self.logger.debug(f"Новый токен сохранён в {self.token_file} с правами 600")
548
707
  except Exception as exc:
549
708
  self.logger.error(f"Не удалось сохранить обновлённый токен: {exc}")
550
709
 
@@ -581,6 +740,7 @@ class AmoCRMClient:
581
740
 
582
741
  :param data_type: Тип данных ('custom_fields', 'pipelines', 'users')
583
742
  :return: Путь к файлу кэша
743
+ :raises ValueError: Если путь выходит за пределы base_dir (path traversal)
584
744
  """
585
745
  # Для custom_fields используем старый путь, если указан (обратная совместимость)
586
746
  if data_type == 'custom_fields' and self.cache_config.file:
@@ -591,8 +751,18 @@ class AmoCRMClient:
591
751
 
592
752
  # Формируем имя файла: {account}_{data_type}.json
593
753
  account_name = self._extract_account_name()
594
- cache_filename = f"{account_name}_{data_type}.json"
595
- return os.path.join(self.cache_config.base_dir, cache_filename)
754
+ # Санитизация account_name: удаляем потенциально опасные символы
755
+ safe_account_name = account_name.replace("..", "").replace("/", "_").replace("\\", "_")
756
+ cache_filename = f"{safe_account_name}_{data_type}.json"
757
+ cache_path = os.path.join(self.cache_config.base_dir, cache_filename)
758
+
759
+ # Проверяем, что результирующий путь находится внутри base_dir
760
+ real_base = os.path.realpath(self.cache_config.base_dir)
761
+ real_cache = os.path.realpath(cache_path)
762
+ if not real_cache.startswith(real_base):
763
+ raise ValueError(f"Cache path traversal detected: {cache_path}")
764
+
765
+ return cache_path
596
766
 
597
767
  def _is_cache_valid(self, data_type, last_updated):
598
768
  """
@@ -677,6 +847,67 @@ class AmoCRMClient:
677
847
  self.logger.error(f"Error loading {data_type} cache: {e}")
678
848
  return None
679
849
 
850
+ def _get_cached_resource(
851
+ self,
852
+ resource_type: str,
853
+ fetch_callable: Callable[[], Any],
854
+ force_update: bool = False
855
+ ) -> Any:
856
+ """
857
+ Универсальный метод для трехуровневого кэширования ресурсов.
858
+
859
+ Уровни кэширования:
860
+ 1. Memory cache (с TTL) - самый быстрый
861
+ 2. File cache (персистентный) - средний
862
+ 3. API request - самый медленный
863
+
864
+ :param resource_type: Тип ресурса ('custom_fields', 'pipelines', 'users')
865
+ :param fetch_callable: Функция для загрузки данных из API
866
+ :param force_update: Если True, игнорирует кэш и загружает данные из API
867
+ :return: Кэшированные или свежие данные
868
+ """
869
+ cache_attr = f"_{resource_type}_cache"
870
+
871
+ # 1. Проверяем memory cache
872
+ if not force_update:
873
+ cached_data = getattr(self, cache_attr, None)
874
+ if cached_data is not None:
875
+ # Проверяем TTL
876
+ cache_time = self._cache_timestamps.get(resource_type, 0)
877
+ if time.time() - cache_time < self._memory_cache_ttl:
878
+ self.logger.debug(f"Using memory-cached {resource_type}.")
879
+ return cached_data
880
+ else:
881
+ # Кэш устарел, очищаем
882
+ self.logger.debug(f"Memory cache for {resource_type} expired.")
883
+ setattr(self, cache_attr, None)
884
+ self._cache_timestamps.pop(resource_type, None)
885
+
886
+ # 2. Проверяем file cache
887
+ if not force_update and self.cache_config.enabled:
888
+ cached_data = self._load_cache(resource_type)
889
+ if cached_data is not None:
890
+ setattr(self, cache_attr, cached_data)
891
+ self._cache_timestamps[resource_type] = time.time()
892
+ self.logger.debug(f"{resource_type} loaded from file cache.")
893
+ return cached_data
894
+
895
+ # 3. Загружаем из API
896
+ self.logger.debug(f"Fetching {resource_type} from API...")
897
+ data = fetch_callable()
898
+
899
+ # Сохраняем в memory cache
900
+ setattr(self, cache_attr, data)
901
+ self._cache_timestamps[resource_type] = time.time()
902
+
903
+ # Сохраняем в file cache
904
+ if self.cache_config.enabled:
905
+ self._save_cache(resource_type, data)
906
+
907
+ data_count = len(data) if isinstance(data, (list, dict)) else "unknown"
908
+ self.logger.debug(f"Fetched {data_count} {resource_type} from API.")
909
+ return data
910
+
680
911
  def _to_timestamp(self, value: Optional[Union[int, float, str, datetime]]) -> Optional[int]:
681
912
  """
682
913
  Преобразует значение даты/времени в Unix timestamp.
@@ -725,16 +956,29 @@ class AmoCRMClient:
725
956
  endpoint: str,
726
957
  params: Optional[dict] = None,
727
958
  data_path: Sequence[str] = ("_embedded",),
959
+ max_pages: Optional[int] = None,
728
960
  ) -> Iterator[dict]:
729
961
  """
730
962
  Возвращает генератор, проходящий по всем страницам ответа API и
731
963
  yielding элементы коллекции.
964
+
965
+ :param endpoint: API endpoint для запроса
966
+ :param params: Параметры запроса
967
+ :param data_path: Путь к данным в response
968
+ :param max_pages: Максимальное количество страниц для итерации (None = без ограничений)
732
969
  """
733
970
  query = dict(params or {})
734
971
  query.setdefault("page", 1)
735
972
  query.setdefault("limit", 250)
736
973
 
974
+ pages_fetched = 0
975
+
737
976
  while True:
977
+ # Проверка на превышение max_pages
978
+ if max_pages is not None and pages_fetched >= max_pages:
979
+ self.logger.warning(f"Reached max_pages limit ({max_pages}), stopping pagination")
980
+ break
981
+
738
982
  response = self._make_request("GET", endpoint, params=query)
739
983
  if not response:
740
984
  break
@@ -744,6 +988,8 @@ class AmoCRMClient:
744
988
  for item in items:
745
989
  yield item
746
990
 
991
+ pages_fetched += 1
992
+
747
993
  total_pages = response.get("_page_count")
748
994
  if total_pages is not None:
749
995
  has_next = query["page"] < total_pages
@@ -764,10 +1010,36 @@ class AmoCRMClient:
764
1010
  include: Optional[Union[str, Sequence[str]]] = None,
765
1011
  limit: int = 250,
766
1012
  extra_params: Optional[dict] = None,
1013
+ max_pages: Optional[int] = None,
767
1014
  ) -> Iterator[dict]:
768
1015
  """
769
1016
  Итератор сделок с фильтрацией по диапазону обновления и воронкам.
770
- """
1017
+
1018
+ :param max_pages: Максимальное количество страниц для итерации (None = без ограничений)
1019
+ :raises ValidationError: Если параметры имеют некорректный тип или значение.
1020
+ """
1021
+ # Валидация limit
1022
+ if not isinstance(limit, int):
1023
+ raise ValidationError(f"limit must be int, got {type(limit).__name__}")
1024
+ if not 1 <= limit <= 250:
1025
+ raise ValidationError(f"limit must be between 1 and 250, got {limit}")
1026
+
1027
+ # Валидация pipeline_ids
1028
+ if pipeline_ids is not None:
1029
+ if isinstance(pipeline_ids, int):
1030
+ if pipeline_ids <= 0:
1031
+ raise ValidationError(f"pipeline_id must be positive, got {pipeline_ids}")
1032
+ elif isinstance(pipeline_ids, (list, tuple)):
1033
+ if not pipeline_ids:
1034
+ raise ValidationError("pipeline_ids cannot be empty")
1035
+ for pid in pipeline_ids:
1036
+ if isinstance(pid, int) and pid <= 0:
1037
+ raise ValidationError(f"pipeline_id must be positive, got {pid}")
1038
+ if not isinstance(pid, (int, str)):
1039
+ raise ValidationError(f"pipeline_id must be int or str, got {type(pid).__name__}")
1040
+ elif not isinstance(pipeline_ids, str):
1041
+ raise ValidationError(f"pipeline_ids must be int, str or sequence, got {type(pipeline_ids).__name__}")
1042
+
771
1043
  params = {"limit": limit, "page": 1}
772
1044
  start_ts = self._to_timestamp(updated_from)
773
1045
  end_ts = self._to_timestamp(updated_to)
@@ -793,7 +1065,7 @@ class AmoCRMClient:
793
1065
  params.update(extra_params)
794
1066
 
795
1067
  yield from self._iterate_paginated(
796
- "/api/v4/leads", params=params, data_path=("_embedded", "leads")
1068
+ "/api/v4/leads", params=params, data_path=("_embedded", "leads"), max_pages=max_pages
797
1069
  )
798
1070
 
799
1071
  def fetch_leads(self, *args, **kwargs) -> List[dict]:
@@ -809,10 +1081,36 @@ class AmoCRMClient:
809
1081
  contact_ids: Optional[Union[int, Sequence[Union[int, str]]]] = None,
810
1082
  limit: int = 250,
811
1083
  extra_params: Optional[dict] = None,
1084
+ max_pages: Optional[int] = None,
812
1085
  ) -> Iterator[dict]:
813
1086
  """
814
1087
  Итератор контактов с фильтрацией по диапазону обновления или списку ID.
815
- """
1088
+
1089
+ :param max_pages: Максимальное количество страниц для итерации (None = без ограничений)
1090
+ :raises ValidationError: Если параметры имеют некорректный тип или значение.
1091
+ """
1092
+ # Валидация limit
1093
+ if not isinstance(limit, int):
1094
+ raise ValidationError(f"limit must be int, got {type(limit).__name__}")
1095
+ if not 1 <= limit <= 250:
1096
+ raise ValidationError(f"limit must be between 1 and 250, got {limit}")
1097
+
1098
+ # Валидация contact_ids
1099
+ if contact_ids is not None:
1100
+ if isinstance(contact_ids, int):
1101
+ if contact_ids <= 0:
1102
+ raise ValidationError(f"contact_id must be positive, got {contact_ids}")
1103
+ elif isinstance(contact_ids, (list, tuple)):
1104
+ if not contact_ids:
1105
+ raise ValidationError("contact_ids cannot be empty")
1106
+ for cid in contact_ids:
1107
+ if isinstance(cid, int) and cid <= 0:
1108
+ raise ValidationError(f"contact_id must be positive, got {cid}")
1109
+ if not isinstance(cid, (int, str)):
1110
+ raise ValidationError(f"contact_id must be int or str, got {type(cid).__name__}")
1111
+ elif not isinstance(contact_ids, str):
1112
+ raise ValidationError(f"contact_ids must be int, str or sequence, got {type(contact_ids).__name__}")
1113
+
816
1114
  params = {"limit": limit, "page": 1}
817
1115
  start_ts = self._to_timestamp(updated_from)
818
1116
  end_ts = self._to_timestamp(updated_to)
@@ -827,7 +1125,7 @@ class AmoCRMClient:
827
1125
  params.update(extra_params)
828
1126
 
829
1127
  yield from self._iterate_paginated(
830
- "/api/v4/contacts", params=params, data_path=("_embedded", "contacts")
1128
+ "/api/v4/contacts", params=params, data_path=("_embedded", "contacts"), max_pages=max_pages
831
1129
  )
832
1130
 
833
1131
  def fetch_contacts(self, *args, **kwargs) -> List[dict]:
@@ -839,6 +1137,8 @@ class AmoCRMClient:
839
1137
  def get_contact_by_id(self, contact_id: Union[int, str], include: Optional[Union[str, Sequence[str]]] = None) -> dict:
840
1138
  """
841
1139
  Получает данные контакта по его ID.
1140
+
1141
+ :raises NotFoundError: Если контакт не найден
842
1142
  """
843
1143
  endpoint = f"/api/v4/contacts/{contact_id}"
844
1144
  params = {}
@@ -849,7 +1149,7 @@ class AmoCRMClient:
849
1149
  params["with"] = ",".join(str(item) for item in include)
850
1150
  data = self._make_request("GET", endpoint, params=params)
851
1151
  if not data or not isinstance(data, dict) or "id" not in data:
852
- raise Exception(f"Contact {contact_id} not found or invalid response.")
1152
+ raise NotFoundError(f"Contact {contact_id} not found or invalid response.")
853
1153
  return data
854
1154
 
855
1155
  def iter_notes(
@@ -861,10 +1161,42 @@ class AmoCRMClient:
861
1161
  entity_ids: Optional[Union[int, Sequence[Union[int, str]]]] = None,
862
1162
  limit: int = 250,
863
1163
  extra_params: Optional[dict] = None,
1164
+ max_pages: Optional[int] = None,
864
1165
  ) -> Iterator[dict]:
865
1166
  """
866
1167
  Итератор примечаний для заданной сущности.
867
- """
1168
+
1169
+ :param max_pages: Максимальное количество страниц для итерации (None = без ограничений)
1170
+ :raises ValidationError: Если параметры имеют некорректный тип или значение.
1171
+ """
1172
+ # Валидация limit
1173
+ if not isinstance(limit, int):
1174
+ raise ValidationError(f"limit must be int, got {type(limit).__name__}")
1175
+ if not 1 <= limit <= 250:
1176
+ raise ValidationError(f"limit must be between 1 and 250, got {limit}")
1177
+
1178
+ # Валидация entity
1179
+ if not isinstance(entity, str):
1180
+ raise ValidationError(f"entity must be str, got {type(entity).__name__}")
1181
+ if not entity:
1182
+ raise ValidationError("entity cannot be empty")
1183
+
1184
+ # Валидация entity_ids
1185
+ if entity_ids is not None:
1186
+ if isinstance(entity_ids, int):
1187
+ if entity_ids <= 0:
1188
+ raise ValidationError(f"entity_id must be positive, got {entity_ids}")
1189
+ elif isinstance(entity_ids, (list, tuple)):
1190
+ if not entity_ids:
1191
+ raise ValidationError("entity_ids cannot be empty")
1192
+ for eid in entity_ids:
1193
+ if isinstance(eid, int) and eid <= 0:
1194
+ raise ValidationError(f"entity_id must be positive, got {eid}")
1195
+ if not isinstance(eid, (int, str)):
1196
+ raise ValidationError(f"entity_id must be int or str, got {type(eid).__name__}")
1197
+ elif not isinstance(entity_ids, str):
1198
+ raise ValidationError(f"entity_ids must be int, str or sequence, got {type(entity_ids).__name__}")
1199
+
868
1200
  mapping = {
869
1201
  "lead": "leads",
870
1202
  "contact": "contacts",
@@ -891,7 +1223,7 @@ class AmoCRMClient:
891
1223
  params.update(extra_params)
892
1224
 
893
1225
  yield from self._iterate_paginated(
894
- endpoint, params=params, data_path=("_embedded", "notes")
1226
+ endpoint, params=params, data_path=("_embedded", "notes"), max_pages=max_pages
895
1227
  )
896
1228
 
897
1229
  def fetch_notes(self, *args, **kwargs) -> List[dict]:
@@ -909,10 +1241,43 @@ class AmoCRMClient:
909
1241
  created_to: Optional[Union[int, float, str, datetime]] = None,
910
1242
  limit: int = 250,
911
1243
  extra_params: Optional[dict] = None,
1244
+ max_pages: Optional[int] = None,
912
1245
  ) -> Iterator[dict]:
913
1246
  """
914
1247
  Итератор событий с фильтрацией по сущности, типам и диапазону дат.
915
- """
1248
+
1249
+ :param max_pages: Максимальное количество страниц для итерации (None = без ограничений)
1250
+ :raises ValidationError: Если параметры имеют некорректный тип или значение.
1251
+ """
1252
+ # Валидация limit
1253
+ if not isinstance(limit, int):
1254
+ raise ValidationError(f"limit must be int, got {type(limit).__name__}")
1255
+ if not 1 <= limit <= 250:
1256
+ raise ValidationError(f"limit must be between 1 and 250, got {limit}")
1257
+
1258
+ # Валидация entity (опционально, но должен быть строкой)
1259
+ if entity is not None:
1260
+ if not isinstance(entity, str):
1261
+ raise ValidationError(f"entity must be str, got {type(entity).__name__}")
1262
+ if not entity:
1263
+ raise ValidationError("entity cannot be empty string")
1264
+
1265
+ # Валидация entity_ids
1266
+ if entity_ids is not None:
1267
+ if isinstance(entity_ids, int):
1268
+ if entity_ids <= 0:
1269
+ raise ValidationError(f"entity_id must be positive, got {entity_ids}")
1270
+ elif isinstance(entity_ids, (list, tuple)):
1271
+ if not entity_ids:
1272
+ raise ValidationError("entity_ids cannot be empty")
1273
+ for eid in entity_ids:
1274
+ if isinstance(eid, int) and eid <= 0:
1275
+ raise ValidationError(f"entity_id must be positive, got {eid}")
1276
+ if not isinstance(eid, (int, str)):
1277
+ raise ValidationError(f"entity_id must be int or str, got {type(eid).__name__}")
1278
+ elif not isinstance(entity_ids, str):
1279
+ raise ValidationError(f"entity_ids must be int, str or sequence, got {type(entity_ids).__name__}")
1280
+
916
1281
  params = {"limit": limit, "page": 1}
917
1282
  if entity:
918
1283
  params["filter[entity]"] = entity
@@ -932,7 +1297,7 @@ class AmoCRMClient:
932
1297
  params.update(extra_params)
933
1298
 
934
1299
  yield from self._iterate_paginated(
935
- "/api/v4/events", params=params, data_path=("_embedded", "events")
1300
+ "/api/v4/events", params=params, data_path=("_embedded", "events"), max_pages=max_pages
936
1301
  )
937
1302
 
938
1303
  def fetch_events(self, *args, **kwargs) -> List[dict]:
@@ -945,15 +1310,18 @@ class AmoCRMClient:
945
1310
  self,
946
1311
  limit: int = 250,
947
1312
  extra_params: Optional[dict] = None,
1313
+ max_pages: Optional[int] = None,
948
1314
  ) -> Iterator[dict]:
949
1315
  """
950
1316
  Итератор пользователей аккаунта.
1317
+
1318
+ :param max_pages: Максимальное количество страниц для итерации (None = без ограничений)
951
1319
  """
952
1320
  params = {"limit": limit, "page": 1}
953
1321
  if extra_params:
954
1322
  params.update(extra_params)
955
1323
  yield from self._iterate_paginated(
956
- "/api/v4/users", params=params, data_path=("_embedded", "users")
1324
+ "/api/v4/users", params=params, data_path=("_embedded", "users"), max_pages=max_pages
957
1325
  )
958
1326
 
959
1327
  def fetch_users(self, *args, **kwargs) -> List[dict]:
@@ -967,53 +1335,31 @@ class AmoCRMClient:
967
1335
  Возвращает список пользователей с кэшированием (по умолчанию 24 часа).
968
1336
 
969
1337
  Использует трехуровневое кэширование:
970
- 1. Memory cache (самый быстрый)
1338
+ 1. Memory cache (с TTL, самый быстрый)
971
1339
  2. File cache (персистентный)
972
1340
  3. API request (если кэш устарел или отсутствует)
973
1341
 
974
1342
  :param force_update: Если True, игнорирует кэш и загружает данные из API
975
1343
  :return: Список пользователей
976
1344
  """
977
- # 1. Проверяем memory cache
978
- if not force_update and self._users_cache is not None:
979
- self.logger.debug("Using memory-cached users.")
980
- return self._users_cache
981
-
982
- # 2. Проверяем file cache
983
- if not force_update and self.cache_config.enabled:
984
- cached_data = self._load_cache('users')
985
- if cached_data is not None:
986
- self._users_cache = cached_data
987
- self.logger.debug("Users loaded from file cache.")
988
- return cached_data
989
-
990
- # 3. Загружаем из API
991
- self.logger.debug("Fetching users from API...")
992
- users = self.fetch_users()
993
-
994
- # Сохраняем в memory cache
995
- self._users_cache = users
996
-
997
- # Сохраняем в file cache
998
- if self.cache_config.enabled:
999
- self._save_cache('users', users)
1000
-
1001
- self.logger.debug(f"Fetched {len(users)} users from API.")
1002
- return users
1345
+ return self._get_cached_resource('users', self.fetch_users, force_update)
1003
1346
 
1004
1347
  def iter_pipelines(
1005
1348
  self,
1006
1349
  limit: int = 250,
1007
1350
  extra_params: Optional[dict] = None,
1351
+ max_pages: Optional[int] = None,
1008
1352
  ) -> Iterator[dict]:
1009
1353
  """
1010
1354
  Итератор воронок со статусами.
1355
+
1356
+ :param max_pages: Максимальное количество страниц для итерации (None = без ограничений)
1011
1357
  """
1012
1358
  params = {"limit": limit, "page": 1}
1013
1359
  if extra_params:
1014
1360
  params.update(extra_params)
1015
1361
  yield from self._iterate_paginated(
1016
- "/api/v4/leads/pipelines", params=params, data_path=("_embedded", "pipelines")
1362
+ "/api/v4/leads/pipelines", params=params, data_path=("_embedded", "pipelines"), max_pages=max_pages
1017
1363
  )
1018
1364
 
1019
1365
  def fetch_pipelines(self, *args, **kwargs) -> List[dict]:
@@ -1026,20 +1372,21 @@ class AmoCRMClient:
1026
1372
  """
1027
1373
  Получает данные сделки по её ID и возвращает объект Deal.
1028
1374
  Если данные отсутствуют или имеют неверную структуру, выбрасывается исключение.
1029
-
1375
+
1030
1376
  :param deal_id: ID сделки для получения
1031
1377
  :param skip_fields_mapping: Если True, не загружает справочник кастомных полей
1032
1378
  (используйте для работы только с ID полей)
1033
1379
  :return: Объект Deal с данными сделки
1380
+ :raises NotFoundError: Если сделка не найдена
1034
1381
  """
1035
1382
  endpoint = f"/api/v4/leads/{deal_id}"
1036
1383
  params = {'with': 'contacts,companies,catalog_elements,loss_reason,tags'}
1037
1384
  data = self._make_request("GET", endpoint, params=params)
1038
-
1385
+
1039
1386
  # Проверяем, что получили данные и что они содержат ключ "id"
1040
1387
  if not data or not isinstance(data, dict) or "id" not in data:
1041
1388
  self.logger.error(f"Deal {deal_id} not found or invalid response: {data}")
1042
- raise Exception(f"Deal {deal_id} not found or invalid response.")
1389
+ raise NotFoundError(f"Deal {deal_id} not found or invalid response.")
1043
1390
 
1044
1391
  custom_config = None if skip_fields_mapping else self.get_custom_fields_mapping()
1045
1392
  self.logger.debug(f"Deal {deal_id} data received (содержимое полей не выводится полностью).")
@@ -1083,45 +1430,12 @@ class AmoCRMClient:
1083
1430
  return None
1084
1431
  return None
1085
1432
 
1086
- def get_custom_fields_mapping(self, force_update=False):
1433
+ def _fetch_custom_fields_from_api(self):
1087
1434
  """
1088
- Возвращает словарь отображения кастомных полей для сделок с кэшированием (по умолчанию 24 часа).
1089
-
1090
- Использует трехуровневое кэширование:
1091
- 1. Memory cache (самый быстрый)
1092
- 2. File cache (персистентный)
1093
- 3. API request (если кэш устарел или отсутствует)
1435
+ Загружает кастомные поля из API (вспомогательный метод).
1094
1436
 
1095
- :param force_update: Если True, игнорирует кэш и загружает данные из API
1096
1437
  :return: Словарь с кастомными полями (ключ - field_id, значение - объект поля)
1097
1438
  """
1098
- # 1. Проверяем memory cache
1099
- if not force_update and self._custom_fields_mapping is not None:
1100
- self.logger.debug("Using memory-cached custom fields mapping.")
1101
- return self._custom_fields_mapping
1102
-
1103
- # 2. Проверяем file cache (с поддержкой старого формата)
1104
- if not force_update and self.cache_config.enabled:
1105
- # Пробуем новый формат
1106
- cached_data = self._load_cache('custom_fields')
1107
- if cached_data is not None:
1108
- self._custom_fields_mapping = cached_data
1109
- self.logger.debug("Custom fields loaded from file cache (new format).")
1110
- return cached_data
1111
-
1112
- # Пробуем старый формат для обратной совместимости
1113
- legacy_cache = self._load_custom_fields_cache()
1114
- if legacy_cache:
1115
- mapping = legacy_cache.get("mapping")
1116
- if mapping:
1117
- self._custom_fields_mapping = mapping
1118
- self.logger.debug("Custom fields loaded from legacy cache format.")
1119
- # Мигрируем в новый формат
1120
- self._save_cache('custom_fields', mapping)
1121
- return mapping
1122
-
1123
- # 3. Загружаем из API
1124
- self.logger.debug("Fetching custom fields from API...")
1125
1439
  mapping = {}
1126
1440
  page = 1
1127
1441
  total_pages = 1
@@ -1136,16 +1450,34 @@ class AmoCRMClient:
1136
1450
  page += 1
1137
1451
  else:
1138
1452
  break
1453
+ return mapping
1139
1454
 
1140
- # Сохраняем в memory cache
1141
- self._custom_fields_mapping = mapping
1455
+ def get_custom_fields_mapping(self, force_update=False):
1456
+ """
1457
+ Возвращает словарь отображения кастомных полей для сделок с кэшированием (по умолчанию 24 часа).
1142
1458
 
1143
- # Сохраняем в file cache (новый формат)
1144
- if self.cache_config.enabled:
1145
- self._save_cache('custom_fields', mapping)
1459
+ Использует трехуровневое кэширование:
1460
+ 1. Memory cache (с TTL, самый быстрый)
1461
+ 2. File cache (персистентный)
1462
+ 3. API request (если кэш устарел или отсутствует)
1146
1463
 
1147
- self.logger.debug(f"Fetched {len(mapping)} custom fields from API.")
1148
- return mapping
1464
+ :param force_update: Если True, игнорирует кэш и загружает данные из API
1465
+ :return: Словарь с кастомными полями (ключ - field_id, значение - объект поля)
1466
+ """
1467
+ # Проверяем legacy cache для обратной совместимости
1468
+ if not force_update and self.cache_config.enabled and self._custom_fields_mapping is None:
1469
+ legacy_cache = self._load_custom_fields_cache()
1470
+ if legacy_cache:
1471
+ mapping = legacy_cache.get("mapping")
1472
+ if mapping:
1473
+ self._custom_fields_mapping = mapping
1474
+ self._cache_timestamps['custom_fields'] = time.time()
1475
+ self.logger.debug("Custom fields loaded from legacy cache format.")
1476
+ # Мигрируем в новый формат
1477
+ self._save_cache('custom_fields', mapping)
1478
+ return mapping
1479
+
1480
+ return self._get_cached_resource('custom_fields', self._fetch_custom_fields_from_api, force_update)
1149
1481
 
1150
1482
  def find_custom_field_id(self, search_term):
1151
1483
  """
@@ -1181,7 +1513,7 @@ class AmoCRMClient:
1181
1513
  :param tags_to_add: Список тегов для добавления к сделке.
1182
1514
  :param tags_to_delete: Список тегов для удаления из сделки.
1183
1515
  :return: Ответ API в формате JSON.
1184
- :raises Exception: Если одно из кастомных полей не найдено.
1516
+ :raises ValidationError: Если одно из кастомных полей не найдено.
1185
1517
  """
1186
1518
  payload = {}
1187
1519
  standard_fields = {
@@ -1208,7 +1540,7 @@ class AmoCRMClient:
1208
1540
  custom_fields.append({"field_id": field_id, "values": [field_value_dict]})
1209
1541
  self.logger.debug(f"Custom field '{key}' found with id {field_id} set to {value}")
1210
1542
  else:
1211
- raise Exception(f"Custom field '{key}' не найден.")
1543
+ raise ValidationError(f"Custom field '{key}' не найден.")
1212
1544
  if custom_fields:
1213
1545
  payload["custom_fields_values"] = custom_fields
1214
1546
  if tags_to_add:
@@ -1412,50 +1744,25 @@ class AmoCRMClient:
1412
1744
  Получает список всех воронок и их статусов из amoCRM.
1413
1745
 
1414
1746
  :return: Список словарей, где каждый словарь содержит данные воронки, а также, если присутствует, вложенные статусы.
1415
- :raises Exception: Если данные не получены или структура ответа неверна.
1747
+ :raises APIError: Если данные не получены или структура ответа неверна.
1416
1748
  """
1417
1749
  pipelines = self.fetch_pipelines()
1418
1750
  if pipelines:
1419
1751
  self.logger.debug(f"Получено {len(pipelines)} воронок")
1420
1752
  return pipelines
1421
1753
  self.logger.error("Не удалось получить воронки из amoCRM")
1422
- raise Exception("Ошибка получения воронок из amoCRM")
1754
+ raise APIError(0, "Ошибка получения воронок из amoCRM")
1423
1755
 
1424
1756
  def get_pipelines_cached(self, force_update=False):
1425
1757
  """
1426
1758
  Возвращает список воронок с кэшированием (по умолчанию 7 дней).
1427
1759
 
1428
1760
  Использует трехуровневое кэширование:
1429
- 1. Memory cache (самый быстрый)
1761
+ 1. Memory cache (с TTL, самый быстрый)
1430
1762
  2. File cache (персистентный)
1431
1763
  3. API request (если кэш устарел или отсутствует)
1432
1764
 
1433
1765
  :param force_update: Если True, игнорирует кэш и загружает данные из API
1434
1766
  :return: Список воронок со статусами
1435
1767
  """
1436
- # 1. Проверяем memory cache
1437
- if not force_update and self._pipelines_cache is not None:
1438
- self.logger.debug("Using memory-cached pipelines.")
1439
- return self._pipelines_cache
1440
-
1441
- # 2. Проверяем file cache
1442
- if not force_update and self.cache_config.enabled:
1443
- cached_data = self._load_cache('pipelines')
1444
- if cached_data is not None:
1445
- self._pipelines_cache = cached_data
1446
- self.logger.debug("Pipelines loaded from file cache.")
1447
- return cached_data
1448
-
1449
- # 3. Загружаем из API
1450
- self.logger.debug("Fetching pipelines from API...")
1451
- pipelines = self.fetch_pipelines()
1452
-
1453
- # Сохраняем в memory cache
1454
- self._pipelines_cache = pipelines
1455
-
1456
- # Сохраняем в file cache
1457
- if self.cache_config.enabled:
1458
- self._save_cache('pipelines', pipelines)
1459
-
1460
- self.logger.debug(f"Fetched {len(pipelines)} pipelines from API.")
1461
- return pipelines
1768
+ return self._get_cached_resource('pipelines', self.fetch_pipelines, force_update)
@@ -0,0 +1,49 @@
1
+ """
2
+ Кастомные исключения для библиотеки amochka.
3
+ """
4
+
5
+
6
+ class AmoCRMError(Exception):
7
+ """Базовое исключение для всех ошибок amoCRM API."""
8
+ pass
9
+
10
+
11
+ class AuthenticationError(AmoCRMError):
12
+ """Исключение при ошибках авторизации и работы с токенами."""
13
+ pass
14
+
15
+
16
+ class RateLimitError(AmoCRMError):
17
+ """Исключение при превышении лимита запросов (429 Too Many Requests)."""
18
+
19
+ def __init__(self, message="Rate limit exceeded", retry_after=None):
20
+ super().__init__(message)
21
+ self.retry_after = retry_after
22
+
23
+
24
+ class NotFoundError(AmoCRMError):
25
+ """Исключение при отсутствии запрашиваемого ресурса (404 Not Found)."""
26
+ pass
27
+
28
+
29
+ class APIError(AmoCRMError):
30
+ """Общее исключение для ошибок API."""
31
+
32
+ def __init__(self, status_code, message):
33
+ self.status_code = status_code
34
+ super().__init__(f"API error {status_code}: {message}")
35
+
36
+
37
+ class ValidationError(AmoCRMError):
38
+ """Исключение при некорректных входных данных."""
39
+ pass
40
+
41
+
42
+ __all__ = [
43
+ "AmoCRMError",
44
+ "AuthenticationError",
45
+ "RateLimitError",
46
+ "NotFoundError",
47
+ "APIError",
48
+ "ValidationError",
49
+ ]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: amochka
3
- Version: 0.3.4
3
+ Version: 0.4.0
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
@@ -2,6 +2,7 @@ README.md
2
2
  pyproject.toml
3
3
  amochka/__init__.py
4
4
  amochka/client.py
5
+ amochka/errors.py
5
6
  amochka/etl.py
6
7
  amochka.egg-info/PKG-INFO
7
8
  amochka.egg-info/SOURCES.txt
@@ -255,8 +255,8 @@ class AmoCRMExtractor:
255
255
  for note in self.client.iter_notes(
256
256
  entity=entity_type,
257
257
  note_type=note_type,
258
- start=updated_from,
259
- end=updated_to,
258
+ updated_from=updated_from,
259
+ updated_to=updated_to,
260
260
  ):
261
261
  count += 1
262
262
  if count % 100 == 0:
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "amochka"
7
- version = "0.3.4"
7
+ version = "0.4.0"
8
8
  description = "Python library for working with amoCRM API with ETL capabilities"
9
9
  readme = "README.md"
10
10
  authors = [
@@ -29,7 +29,7 @@ class DummyClient(AmoCRMClient):
29
29
  "expires_at": str(time.time() + 3600)
30
30
  }
31
31
  super().__init__(
32
- base_url="https://example.com",
32
+ base_url="https://example.amocrm.ru",
33
33
  token_file=json.dumps(token_data),
34
34
  cache_config=CacheConfig.disabled(),
35
35
  disable_logging=True
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes