ozonapi-async 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. ozonapi/__init__.py +9 -0
  2. ozonapi/seller/__init__.py +29 -0
  3. ozonapi/seller/common/__init__.py +0 -0
  4. ozonapi/seller/common/enumerations/__init__.py +0 -0
  5. ozonapi/seller/common/enumerations/delivery.py +16 -0
  6. ozonapi/seller/common/enumerations/localization.py +39 -0
  7. ozonapi/seller/common/enumerations/postings.py +205 -0
  8. ozonapi/seller/common/enumerations/products.py +225 -0
  9. ozonapi/seller/common/enumerations/requests.py +26 -0
  10. ozonapi/seller/common/enumerations/warehouses.py +54 -0
  11. ozonapi/seller/core/__init__.py +14 -0
  12. ozonapi/seller/core/config.py +88 -0
  13. ozonapi/seller/core/core.py +404 -0
  14. ozonapi/seller/core/exceptions.py +32 -0
  15. ozonapi/seller/core/method_rate_limiter.py +199 -0
  16. ozonapi/seller/core/rate_limiter.py +174 -0
  17. ozonapi/seller/core/sessions.py +75 -0
  18. ozonapi/seller/methods/__init__.py +15 -0
  19. ozonapi/seller/methods/attributes_and_characteristics.py +177 -0
  20. ozonapi/seller/methods/barcodes.py +84 -0
  21. ozonapi/seller/methods/fbs.py +69 -0
  22. ozonapi/seller/methods/prices_and_stocks.py +147 -0
  23. ozonapi/seller/methods/products.py +673 -0
  24. ozonapi/seller/methods/warehouses.py +80 -0
  25. ozonapi/seller/schemas/__init__.py +0 -0
  26. ozonapi/seller/schemas/attributes_and_characteristics/__init__.py +21 -0
  27. ozonapi/seller/schemas/attributes_and_characteristics/base.py +52 -0
  28. ozonapi/seller/schemas/attributes_and_characteristics/v1__description_category_attribute.py +108 -0
  29. ozonapi/seller/schemas/attributes_and_characteristics/v1__description_category_attribute_values.py +38 -0
  30. ozonapi/seller/schemas/attributes_and_characteristics/v1__description_category_attribute_values_search.py +36 -0
  31. ozonapi/seller/schemas/attributes_and_characteristics/v1__description_category_tree.py +61 -0
  32. ozonapi/seller/schemas/barcodes/__init__.py +12 -0
  33. ozonapi/seller/schemas/barcodes/v1__barcode_add.py +66 -0
  34. ozonapi/seller/schemas/barcodes/v1__barcode_generate.py +46 -0
  35. ozonapi/seller/schemas/base.py +94 -0
  36. ozonapi/seller/schemas/fbs/__init__.py +9 -0
  37. ozonapi/seller/schemas/fbs/v3__posting_fbs_unfulfilled_list.py +764 -0
  38. ozonapi/seller/schemas/prices_and_stocks/__init__.py +16 -0
  39. ozonapi/seller/schemas/prices_and_stocks/base.py +26 -0
  40. ozonapi/seller/schemas/prices_and_stocks/v1__product_info_stocks_by_warehouse_fbs.py +55 -0
  41. ozonapi/seller/schemas/prices_and_stocks/v4__product_info_stocks.py +113 -0
  42. ozonapi/seller/schemas/prices_and_stocks/v5__product_info_prices.py +292 -0
  43. ozonapi/seller/schemas/products/__init__.py +51 -0
  44. ozonapi/seller/schemas/products/base.py +162 -0
  45. ozonapi/seller/schemas/products/v1__product_archive.py +20 -0
  46. ozonapi/seller/schemas/products/v1__product_attributes_update.py +79 -0
  47. ozonapi/seller/schemas/products/v1__product_import_by_sku.py +71 -0
  48. ozonapi/seller/schemas/products/v1__product_import_info.py +103 -0
  49. ozonapi/seller/schemas/products/v1__product_info_subscription.py +39 -0
  50. ozonapi/seller/schemas/products/v1__product_rating_by_sku.py +116 -0
  51. ozonapi/seller/schemas/products/v1__product_related_sku_get.py +81 -0
  52. ozonapi/seller/schemas/products/v1__product_unarchive.py +20 -0
  53. ozonapi/seller/schemas/products/v1__product_update_offer_id.py +52 -0
  54. ozonapi/seller/schemas/products/v2__product_pictures_info.py +73 -0
  55. ozonapi/seller/schemas/products/v2__products_delete.py +57 -0
  56. ozonapi/seller/schemas/products/v3__product_import.py +142 -0
  57. ozonapi/seller/schemas/products/v3__product_info_list.py +482 -0
  58. ozonapi/seller/schemas/products/v3__product_list.py +107 -0
  59. ozonapi/seller/schemas/products/v4__product_info_attributes.py +137 -0
  60. ozonapi/seller/schemas/warehouses/__init__.py +11 -0
  61. ozonapi/seller/schemas/warehouses/v1__delivery_method_list.py +95 -0
  62. ozonapi/seller/schemas/warehouses/v1__warehouse_list.py +109 -0
  63. ozonapi_async-0.1.0.dist-info/METADATA +648 -0
  64. ozonapi_async-0.1.0.dist-info/RECORD +67 -0
  65. ozonapi_async-0.1.0.dist-info/WHEEL +5 -0
  66. ozonapi_async-0.1.0.dist-info/licenses/LICENSE +21 -0
  67. ozonapi_async-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,88 @@
1
+ from typing import Optional
2
+
3
+ from pydantic import Field, field_validator, ConfigDict
4
+ from pydantic_settings import BaseSettings
5
+
6
+
7
+ class APIConfig(BaseSettings):
8
+ """Конфигурация API клиента."""
9
+
10
+ client_id: Optional[str] = Field(
11
+ default=None,
12
+ description="Идентификатор клиента Ozon",
13
+ )
14
+ api_key: Optional[str] = Field(
15
+ default=None,
16
+ description="Авторизационный ключ Ozon Seller API",
17
+ )
18
+ log_level: Optional[str] = Field(
19
+ default="DEBUG",
20
+ description="Уровень логирования."
21
+ )
22
+ base_url: str = Field(
23
+ default="https://api-seller.ozon.ru",
24
+ description="Базовый URL API Ozon"
25
+ )
26
+ max_requests_per_second: int = Field(
27
+ default=50,
28
+ ge=1,
29
+ le=50,
30
+ description="Максимальное количество запросов в секунду (50 по документации Ozon)"
31
+ )
32
+ cleanup_interval: float = Field(
33
+ default=300.0,
34
+ gt=0,
35
+ description="Интервал очистки неиспользуемых ресурсов в секундах"
36
+ )
37
+ min_instance_ttl: float = Field(
38
+ default=300.0,
39
+ gt=0,
40
+ description="Минимальное время жизни ограничителей запросов для ClientID в секундах"
41
+ )
42
+ connector_limit: int = Field(
43
+ default=100,
44
+ ge=1,
45
+ description="Лимит одновременных соединений для клиента"
46
+ )
47
+ request_timeout: float = Field(
48
+ default=30.0,
49
+ gt=0,
50
+ description="Таймаут запросов в секундах"
51
+ )
52
+ max_retries: int = Field(
53
+ default=3,
54
+ ge=0,
55
+ le=10,
56
+ description="Максимальное количество повторных попыток для неудачных запросов"
57
+ )
58
+ retry_min_wait: float = Field(
59
+ default=4.0,
60
+ gt=0,
61
+ description="Минимальная задержка между повторами неудачных запросов в секундах"
62
+ )
63
+ retry_max_wait: float = Field(
64
+ default=10.0,
65
+ gt=0,
66
+ description="Максимальная задержка между повторами неудачных запросов в секундах"
67
+ )
68
+
69
+ @field_validator("base_url")
70
+ def validate_base_url(cls, v: str) -> str:
71
+ """Валидация базового URL."""
72
+ if not v.startswith(("http://", "https://")):
73
+ raise ValueError("URL должен начинаться с http:// или https://")
74
+ return v.rstrip("/")
75
+
76
+ @field_validator("retry_max_wait")
77
+ def validate_retry_times(cls, v: float, info) -> float:
78
+ """Валидация времени повторов."""
79
+ if "retry_min_wait" in info.data and v < info.data["retry_min_wait"]:
80
+ raise ValueError("retry_max_wait должен быть больше или равен retry_min_wait")
81
+ return v
82
+
83
+ model_config = ConfigDict(
84
+ env_prefix='OZON_SELLER_', #type: ignore
85
+ case_sensitive=False, #type: ignore
86
+ env_file='.env', #type: ignore
87
+ extra='ignore',
88
+ )
@@ -0,0 +1,404 @@
1
+ import asyncio
2
+ import sys
3
+ from types import TracebackType
4
+ from typing import Any, Literal, Optional, ClassVar
5
+
6
+ import aiohttp
7
+ from loguru import logger
8
+ from tenacity import (
9
+ retry,
10
+ retry_if_exception_type,
11
+ stop_after_attempt,
12
+ wait_exponential,
13
+ before_sleep_log,
14
+ )
15
+
16
+ from .config import APIConfig
17
+ from .method_rate_limiter import MethodRateLimiterManager
18
+ from .rate_limiter import RateLimiterConfig, RateLimiterManager
19
+ from .sessions import SessionManager
20
+ from .exceptions import (
21
+ APIClientError,
22
+ APIConflictError,
23
+ APIError,
24
+ APIForbiddenError,
25
+ APINotFoundError,
26
+ APIServerError,
27
+ )
28
+
29
+ logger.remove()
30
+ logger.add(
31
+ sys.stderr,
32
+ level=APIConfig().log_level,
33
+ enqueue=True,
34
+ )
35
+
36
+
37
+ class APIManager:
38
+ """
39
+ Базовый класс для работы с API.
40
+
41
+ Предоставляет основные методы для взаимодействия с API, включая управление сессией,
42
+ аутентификацию и базовые HTTP-запросы.
43
+ """
44
+
45
+ # Общие менеджеры для всех экземпляров класса
46
+ _rate_limiter_manager: ClassVar[Optional[RateLimiterManager]] = None
47
+ _session_manager: ClassVar[Optional[SessionManager]] = None
48
+ _method_rate_limiter_manager: ClassVar[Optional[MethodRateLimiterManager]] = None
49
+ _initialized: ClassVar[bool] = False
50
+ _instance_count: ClassVar[int] = 0
51
+
52
+ def __init__(
53
+ self,
54
+ client_id: Optional[str] = None,
55
+ api_key: Optional[str] = None,
56
+ config: Optional[APIConfig] = None
57
+ ) -> None:
58
+ """
59
+ Инициализация клиента API Ozon.
60
+
61
+ Args:
62
+ client_id: ID клиента для доступа к API
63
+ api_key: Ключ API для аутентификации
64
+ config: Конфигурация клиента
65
+ """
66
+ self._config = config or self.load_config()
67
+ self._client_id = client_id or self._config.client_id
68
+ self._api_key = api_key or self._config.api_key
69
+ self._instance_id = id(self)
70
+ self._registered = False
71
+ self._closed = False
72
+
73
+ if self._client_id is None or self._api_key is None:
74
+ raise ValueError(
75
+ "Не предоставлены авторизационные данные. Проверьте указание client_id и api_key."
76
+ )
77
+
78
+ if APIManager._rate_limiter_manager is None:
79
+ APIManager._rate_limiter_manager = RateLimiterManager(
80
+ cleanup_interval=self._config.cleanup_interval
81
+ )
82
+ if APIManager._session_manager is None:
83
+ APIManager._session_manager = SessionManager(
84
+ timeout=self._config.request_timeout,
85
+ connector_limit=self._config.connector_limit
86
+ )
87
+ if APIManager._method_rate_limiter_manager is None:
88
+ APIManager._method_rate_limiter_manager = MethodRateLimiterManager(
89
+ cleanup_interval=self._config.cleanup_interval
90
+ )
91
+
92
+ APIManager._instance_count += 1
93
+ self._validate_credentials()
94
+ logger.debug(f"API-клиент инициализирован для ClientID {self._client_id}")
95
+
96
+ @classmethod
97
+ def load_config(cls) -> APIConfig:
98
+ """Создает конфигурацию с загрузкой из .env файла."""
99
+ return APIConfig()
100
+
101
+ @classmethod
102
+ async def initialize(cls) -> None:
103
+ """Инициализация ресурсов."""
104
+ if not cls._initialized:
105
+ if cls._rate_limiter_manager:
106
+ await cls._rate_limiter_manager.start()
107
+ if cls._method_rate_limiter_manager:
108
+ await cls._method_rate_limiter_manager.start()
109
+ cls._initialized = True
110
+ logger.debug("Выполнена инициализация ресурсов API-менеджера")
111
+
112
+ @classmethod
113
+ async def shutdown(cls) -> None:
114
+ """Очистка ресурсов."""
115
+ if cls._initialized:
116
+ if cls._rate_limiter_manager:
117
+ await cls._rate_limiter_manager.shutdown()
118
+ if cls._method_rate_limiter_manager:
119
+ await cls._method_rate_limiter_manager.shutdown()
120
+ if cls._session_manager:
121
+ await cls._session_manager.close_all()
122
+ cls._initialized = False
123
+ logger.debug("Выполнена деинициализация ресурсов API-менеджера")
124
+
125
+ def _validate_credentials(self) -> None:
126
+ """Валидация учетных данных."""
127
+ if not self._client_id or not isinstance(self._client_id, str):
128
+ raise ValueError("client_id не должен быть пустой строкой")
129
+ if not self._api_key or not isinstance(self._api_key, str):
130
+ raise ValueError("api_key не должен быть пустой строкой")
131
+
132
+ if self._config.max_requests_per_second > 50:
133
+ logger.warning(
134
+ f"Максимальное кол-во запросов в секунду согласно документации Ozon - 50. "
135
+ f"Установлено: {self._config.max_requests_per_second}"
136
+ )
137
+
138
+ async def _ensure_registered(self) -> None:
139
+ """Гарантирует регистрацию экземпляра в менеджерах."""
140
+ if self._closed:
141
+ raise RuntimeError(f"Регистрация API-клиента отменена для ClientID {self._client_id}")
142
+
143
+ if not self._registered and self._rate_limiter_manager:
144
+ await self._rate_limiter_manager.register_instance(
145
+ self._client_id, self._instance_id
146
+ )
147
+ self._registered = True
148
+
149
+ async def __aenter__(self) -> "APIManager":
150
+ """Асинхронный контекстный менеджер."""
151
+ if self._closed:
152
+ raise RuntimeError(f"Невозможно использовать закрытый API-клиент для ClientID {self._client_id}")
153
+
154
+ await self.initialize()
155
+ await self._ensure_registered()
156
+ return self
157
+
158
+ async def __aexit__(
159
+ self,
160
+ exc_type: Optional[type[BaseException]],
161
+ exc_val: Optional[BaseException],
162
+ exc_tb: Optional[TracebackType],
163
+ ) -> None:
164
+ """Очистка ресурсов при выходе из контекста."""
165
+ await self.close()
166
+
167
+ async def close(self) -> None:
168
+ if self._closed:
169
+ return
170
+
171
+ self._closed = True
172
+
173
+ if self._registered and self._rate_limiter_manager:
174
+ await self._rate_limiter_manager.unregister_instance(
175
+ self._client_id, self._instance_id
176
+ )
177
+ self._registered = False
178
+
179
+ APIManager._instance_count -= 1
180
+
181
+ if APIManager._instance_count == 0:
182
+ if APIManager._session_manager:
183
+ await APIManager._session_manager.close_all()
184
+
185
+ logger.debug(f"Работа API-клиента для ClientID {self._client_id} завершена")
186
+
187
+ @property
188
+ def client_id(self) -> str:
189
+ """ID клиента."""
190
+ return self._client_id
191
+
192
+ @property
193
+ def config(self) -> APIConfig:
194
+ """Конфигурация клиента."""
195
+ return self._config
196
+
197
+ @property
198
+ def is_closed(self) -> bool:
199
+ """Проверяет закрыт ли клиент."""
200
+ return self._closed
201
+
202
+ @classmethod
203
+ def get_instance_count(cls) -> int:
204
+ """Получает количество активных экземпляров."""
205
+ return cls._instance_count
206
+
207
+ def _create_retry_decorator(self):
208
+ """Создает декоратор повторов на основе конфигурации."""
209
+
210
+ def log_retry(retry_state):
211
+ logger.debug(
212
+ f"Попытка {retry_state.attempt_number} совершения запроса для ClientID {self._client_id}"
213
+ f" завершилась исключением: {retry_state.outcome.exception()}"
214
+ )
215
+
216
+ return retry(
217
+ retry=retry_if_exception_type(
218
+ (
219
+ # Обрабатываемые механизмом retry ошибки
220
+ APIServerError,
221
+ asyncio.TimeoutError
222
+ )
223
+ ),
224
+ stop=stop_after_attempt(self._config.max_retries + 1),
225
+ wait=wait_exponential(
226
+ multiplier=1,
227
+ min=self._config.retry_min_wait,
228
+ max=self._config.retry_max_wait
229
+ ),
230
+ before_sleep=before_sleep_log(logger, 30),
231
+ after=log_retry,
232
+ reraise=True,
233
+ )
234
+
235
+ @staticmethod
236
+ def _handle_error_response(response, data: dict, log_context: dict) -> Optional[APIError]:
237
+ """
238
+ Обработка ошибочных ответов API.
239
+
240
+ Args:
241
+ response: Объект ответа
242
+ data: Данные ответа
243
+ log_context: Контекст для логирования
244
+
245
+ Returns:
246
+ APIError или None если ошибка не критическая
247
+ """
248
+ code = data.get("code", response.status)
249
+ message = data.get("message", "Unknown error")
250
+ details = data.get("details", [])
251
+
252
+ log_context.update({
253
+ "error_code": code,
254
+ "error_message": message,
255
+ "error_details": details,
256
+ })
257
+
258
+ logger.error(f"Ошибка API: {message}", extra=log_context)
259
+
260
+ error_map = {
261
+ 400: APIClientError,
262
+ 403: APIForbiddenError,
263
+ 404: APINotFoundError,
264
+ 409: APIConflictError,
265
+ 500: APIServerError,
266
+ }
267
+
268
+ exc_class = error_map.get(response.status, APIError)
269
+ return exc_class(code, message, details)
270
+
271
+ async def _request(
272
+ self,
273
+ method: Literal["post", "get", "put", "delete"] = "post",
274
+ api_name: str = "Ozon Seller API",
275
+ api_version: str = "v1",
276
+ endpoint: str = "",
277
+ json: Optional[dict[str, Any]] = None,
278
+ params: Optional[dict[str, Any]] = None,
279
+ ) -> dict[str, Any]:
280
+ """
281
+ Выполняет HTTP-запрос к API Ozon с учетом ограничения запросов.
282
+
283
+ Args:
284
+ method: HTTP метод запроса
285
+ api_name: Название API
286
+ api_version: Версия API
287
+ endpoint: Конечная точка API
288
+ json: Данные для отправки в формате JSON
289
+ params: Query parameters
290
+
291
+ Returns:
292
+ Ответ от API в формате JSON
293
+
294
+ Raises:
295
+ APIClientError: При ошибках клиента (400)
296
+ APIForbiddenError: При ошибках доступа (403)
297
+ APINotFoundError: При отсутствии ресурса (404)
298
+ APIConflictError: При конфликте данных (409)
299
+ APIServerError: При ошибках сервера (500)
300
+ APIError: При прочих ошибках
301
+ """
302
+ if self._closed:
303
+ raise RuntimeError("API-клиент остановлен")
304
+
305
+ if not self._rate_limiter_manager or not self._session_manager:
306
+ raise RuntimeError("API-клиент не инициализирован")
307
+
308
+ url = f"{self._config.base_url}/{api_version}/{endpoint}"
309
+
310
+ log_context = {
311
+ "api_name": api_name,
312
+ "client_id": self._client_id,
313
+ "method": method,
314
+ "endpoint": endpoint,
315
+ "api_version": api_version,
316
+ "url": url,
317
+ "has_payload": json is not None,
318
+ }
319
+
320
+ logger.debug("Отправка запроса к API", extra=log_context)
321
+
322
+ await self._ensure_registered()
323
+
324
+ limiter_config = RateLimiterConfig(
325
+ max_requests=self._config.max_requests_per_second,
326
+ )
327
+ rate_limiter = await self._rate_limiter_manager.get_limiter(
328
+ self._client_id, limiter_config
329
+ )
330
+
331
+ retry_decorator = self._create_retry_decorator()
332
+
333
+ async def _execute_request():
334
+ """Выполнение запроса."""
335
+ async with self._session_manager.get_session(
336
+ self._client_id, self._api_key, self._instance_id
337
+ ) as session:
338
+ async with rate_limiter:
339
+ try:
340
+ async with session.request(
341
+ method, url, json=json, params=params
342
+ ) as response:
343
+ data = await response.json()
344
+
345
+ log_context.update({
346
+ "status_code": response.status,
347
+ "response_size": len(str(data))
348
+ })
349
+
350
+ if response.status >= 400:
351
+ error = self._handle_error_response(response, data, log_context)
352
+ if error:
353
+ raise error
354
+
355
+ logger.debug("Успешный ответ от API", extra=log_context)
356
+ return data
357
+
358
+ except asyncio.TimeoutError:
359
+ logger.error("Таймаут запроса к API", extra=log_context)
360
+ raise APIError(408, "Request timeout")
361
+ except asyncio.CancelledError:
362
+ logger.warning("Запрос к API отменен", extra=log_context)
363
+ raise
364
+ except (aiohttp.ClientError, ConnectionError, OSError) as e:
365
+ log_context.update({
366
+ "error_type": type(e).__name__,
367
+ "error_message": str(e)
368
+ })
369
+ logger.error(
370
+ f"Сетевая ошибка при выполнении запроса к API: {str(e)}",
371
+ extra=log_context
372
+ )
373
+ raise APIError(0, f"Network error: {str(e)}")
374
+
375
+ _execute_request_retry = retry_decorator(_execute_request)
376
+ return await _execute_request_retry()
377
+
378
+ @classmethod
379
+ async def get_active_client_ids(cls) -> list[str]:
380
+ """Возвращает список client_id с активными экземплярами."""
381
+ if cls._rate_limiter_manager:
382
+ return await cls._rate_limiter_manager.get_active_client_ids()
383
+ return list()
384
+
385
+ @classmethod
386
+ async def get_rate_limiter_stats(cls) -> dict[str, int]:
387
+ """Возвращает статистику по ограничителям запросов."""
388
+ if cls._rate_limiter_manager:
389
+ return await cls._rate_limiter_manager.get_instance_stats()
390
+ return dict()
391
+
392
+ @classmethod
393
+ async def get_detailed_stats(cls) -> dict[str, dict[str, Any]]:
394
+ """Возвращает детальную статистику."""
395
+ if cls._rate_limiter_manager:
396
+ return await cls._rate_limiter_manager.get_limiter_stats()
397
+ return dict()
398
+
399
+ @classmethod
400
+ async def get_method_limiter_stats(cls) -> dict[str, dict[str, Any]]:
401
+ """Возвращает статистику по ограничителям методов."""
402
+ if cls._method_rate_limiter_manager:
403
+ return await cls._method_rate_limiter_manager.get_limiter_stats()
404
+ return dict()
@@ -0,0 +1,32 @@
1
+ class APIError(Exception):
2
+ """Базовое исключение для ошибок API."""
3
+ def __init__(self, code: int, message: str, details: list | None = None):
4
+ self.code = code
5
+ self.message = message
6
+ self.details = details or []
7
+ super().__init__(f"API Error {code}: {message}")
8
+
9
+
10
+ class APIClientError(APIError):
11
+ """Ошибка 400: Неверный параметр."""
12
+ pass
13
+
14
+
15
+ class APIForbiddenError(APIError):
16
+ """Ошибка 403: Доступ запрещён."""
17
+ pass
18
+
19
+
20
+ class APINotFoundError(APIError):
21
+ """Ошибка 404: Ответ не найден."""
22
+ pass
23
+
24
+
25
+ class APIConflictError(APIError):
26
+ """Ошибка 409: Конфликт запроса."""
27
+ pass
28
+
29
+
30
+ class APIServerError(APIError):
31
+ """Ошибка 500: Внутренняя ошибка сервера."""
32
+ pass