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.
- ozonapi/__init__.py +9 -0
- ozonapi/seller/__init__.py +29 -0
- ozonapi/seller/common/__init__.py +0 -0
- ozonapi/seller/common/enumerations/__init__.py +0 -0
- ozonapi/seller/common/enumerations/delivery.py +16 -0
- ozonapi/seller/common/enumerations/localization.py +39 -0
- ozonapi/seller/common/enumerations/postings.py +205 -0
- ozonapi/seller/common/enumerations/products.py +225 -0
- ozonapi/seller/common/enumerations/requests.py +26 -0
- ozonapi/seller/common/enumerations/warehouses.py +54 -0
- ozonapi/seller/core/__init__.py +14 -0
- ozonapi/seller/core/config.py +88 -0
- ozonapi/seller/core/core.py +404 -0
- ozonapi/seller/core/exceptions.py +32 -0
- ozonapi/seller/core/method_rate_limiter.py +199 -0
- ozonapi/seller/core/rate_limiter.py +174 -0
- ozonapi/seller/core/sessions.py +75 -0
- ozonapi/seller/methods/__init__.py +15 -0
- ozonapi/seller/methods/attributes_and_characteristics.py +177 -0
- ozonapi/seller/methods/barcodes.py +84 -0
- ozonapi/seller/methods/fbs.py +69 -0
- ozonapi/seller/methods/prices_and_stocks.py +147 -0
- ozonapi/seller/methods/products.py +673 -0
- ozonapi/seller/methods/warehouses.py +80 -0
- ozonapi/seller/schemas/__init__.py +0 -0
- ozonapi/seller/schemas/attributes_and_characteristics/__init__.py +21 -0
- ozonapi/seller/schemas/attributes_and_characteristics/base.py +52 -0
- ozonapi/seller/schemas/attributes_and_characteristics/v1__description_category_attribute.py +108 -0
- ozonapi/seller/schemas/attributes_and_characteristics/v1__description_category_attribute_values.py +38 -0
- ozonapi/seller/schemas/attributes_and_characteristics/v1__description_category_attribute_values_search.py +36 -0
- ozonapi/seller/schemas/attributes_and_characteristics/v1__description_category_tree.py +61 -0
- ozonapi/seller/schemas/barcodes/__init__.py +12 -0
- ozonapi/seller/schemas/barcodes/v1__barcode_add.py +66 -0
- ozonapi/seller/schemas/barcodes/v1__barcode_generate.py +46 -0
- ozonapi/seller/schemas/base.py +94 -0
- ozonapi/seller/schemas/fbs/__init__.py +9 -0
- ozonapi/seller/schemas/fbs/v3__posting_fbs_unfulfilled_list.py +764 -0
- ozonapi/seller/schemas/prices_and_stocks/__init__.py +16 -0
- ozonapi/seller/schemas/prices_and_stocks/base.py +26 -0
- ozonapi/seller/schemas/prices_and_stocks/v1__product_info_stocks_by_warehouse_fbs.py +55 -0
- ozonapi/seller/schemas/prices_and_stocks/v4__product_info_stocks.py +113 -0
- ozonapi/seller/schemas/prices_and_stocks/v5__product_info_prices.py +292 -0
- ozonapi/seller/schemas/products/__init__.py +51 -0
- ozonapi/seller/schemas/products/base.py +162 -0
- ozonapi/seller/schemas/products/v1__product_archive.py +20 -0
- ozonapi/seller/schemas/products/v1__product_attributes_update.py +79 -0
- ozonapi/seller/schemas/products/v1__product_import_by_sku.py +71 -0
- ozonapi/seller/schemas/products/v1__product_import_info.py +103 -0
- ozonapi/seller/schemas/products/v1__product_info_subscription.py +39 -0
- ozonapi/seller/schemas/products/v1__product_rating_by_sku.py +116 -0
- ozonapi/seller/schemas/products/v1__product_related_sku_get.py +81 -0
- ozonapi/seller/schemas/products/v1__product_unarchive.py +20 -0
- ozonapi/seller/schemas/products/v1__product_update_offer_id.py +52 -0
- ozonapi/seller/schemas/products/v2__product_pictures_info.py +73 -0
- ozonapi/seller/schemas/products/v2__products_delete.py +57 -0
- ozonapi/seller/schemas/products/v3__product_import.py +142 -0
- ozonapi/seller/schemas/products/v3__product_info_list.py +482 -0
- ozonapi/seller/schemas/products/v3__product_list.py +107 -0
- ozonapi/seller/schemas/products/v4__product_info_attributes.py +137 -0
- ozonapi/seller/schemas/warehouses/__init__.py +11 -0
- ozonapi/seller/schemas/warehouses/v1__delivery_method_list.py +95 -0
- ozonapi/seller/schemas/warehouses/v1__warehouse_list.py +109 -0
- ozonapi_async-0.1.0.dist-info/METADATA +648 -0
- ozonapi_async-0.1.0.dist-info/RECORD +67 -0
- ozonapi_async-0.1.0.dist-info/WHEEL +5 -0
- ozonapi_async-0.1.0.dist-info/licenses/LICENSE +21 -0
- ozonapi_async-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import hashlib
|
|
3
|
+
import time
|
|
4
|
+
from functools import wraps
|
|
5
|
+
from typing import Optional, Any
|
|
6
|
+
|
|
7
|
+
from aiolimiter import AsyncLimiter
|
|
8
|
+
from loguru import logger
|
|
9
|
+
from pydantic import BaseModel, Field
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class MethodRateLimitConfig(BaseModel):
|
|
13
|
+
"""Конфигурация ограничений для конкретного метода API."""
|
|
14
|
+
limit_requests: int = Field(..., ge=1, description="Максимальное количество запросов в интервал времени")
|
|
15
|
+
interval_seconds: float = Field(..., gt=0, description="Интервал ограничения количества запросов в секундах")
|
|
16
|
+
method_identifier: str = Field(..., description="Уникальный идентификатор вызываемого метода")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class MethodRateLimiterManager:
|
|
20
|
+
"""
|
|
21
|
+
Менеджер для управления ограничителями запросов по методам API.
|
|
22
|
+
Обеспечивает раздельные лимиты для каждого метода и client_id.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(self, cleanup_interval: float = 300.0, min_instance_ttl: float = 300.0) -> None:
|
|
26
|
+
self._rate_limiters: dict[str, AsyncLimiter] = {}
|
|
27
|
+
self._limiter_configs: dict[str, MethodRateLimitConfig] = {}
|
|
28
|
+
self._last_used: dict[str, float] = {}
|
|
29
|
+
self._last_instance_creation: dict[str, float] = {}
|
|
30
|
+
self._lock = asyncio.Lock()
|
|
31
|
+
self._cleanup_task: Optional[asyncio.Task] = None
|
|
32
|
+
self._shutdown = False
|
|
33
|
+
self._cleanup_interval = cleanup_interval
|
|
34
|
+
self._min_instance_ttl = min_instance_ttl
|
|
35
|
+
|
|
36
|
+
async def start(self) -> None:
|
|
37
|
+
"""Запуск фоновых задач менеджера."""
|
|
38
|
+
if self._cleanup_task is None:
|
|
39
|
+
self._cleanup_task = asyncio.create_task(self._cleanup_loop())
|
|
40
|
+
logger.debug("Менеджер ограничителей методов запущен")
|
|
41
|
+
|
|
42
|
+
async def shutdown(self) -> None:
|
|
43
|
+
"""Корректное завершение работы менеджера."""
|
|
44
|
+
self._shutdown = True
|
|
45
|
+
if self._cleanup_task:
|
|
46
|
+
self._cleanup_task.cancel()
|
|
47
|
+
try:
|
|
48
|
+
await self._cleanup_task
|
|
49
|
+
except asyncio.CancelledError:
|
|
50
|
+
logger.debug("Задача очистки ограничителей методов отменена")
|
|
51
|
+
self._cleanup_task = None
|
|
52
|
+
logger.debug("Менеджер ограничителей методов остановлен")
|
|
53
|
+
|
|
54
|
+
@staticmethod
|
|
55
|
+
def _generate_limiter_key(client_id: str, method_identifier: str) -> str:
|
|
56
|
+
"""Генерирует уникальный ключ для ограничителя метода."""
|
|
57
|
+
key_data = f"{client_id}:{method_identifier}"
|
|
58
|
+
return hashlib.md5(key_data.encode()).hexdigest()
|
|
59
|
+
|
|
60
|
+
async def get_limiter(self, client_id: str, config: MethodRateLimitConfig) -> AsyncLimiter:
|
|
61
|
+
"""
|
|
62
|
+
Получает ограничитель для указанного метода и client_id.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
client_id: Идентификатор клиента
|
|
66
|
+
config: Конфигурация ограничителя метода
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
AsyncLimiter: Ограничитель для метода
|
|
70
|
+
"""
|
|
71
|
+
limiter_key = self._generate_limiter_key(client_id, config.method_identifier)
|
|
72
|
+
current_time = time.monotonic()
|
|
73
|
+
|
|
74
|
+
async with self._lock:
|
|
75
|
+
if limiter_key not in self._rate_limiters:
|
|
76
|
+
limiter = AsyncLimiter(config.limit_requests, config.interval_seconds)
|
|
77
|
+
self._rate_limiters[limiter_key] = limiter
|
|
78
|
+
self._limiter_configs[limiter_key] = config
|
|
79
|
+
self._last_instance_creation[limiter_key] = current_time
|
|
80
|
+
logger.debug(
|
|
81
|
+
f"Инициализирован ограничитель запросов для метода {config.method_identifier} "
|
|
82
|
+
f"ClientID {client_id}: {config.limit_requests} запросов в {config.interval_seconds} сек"
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
self._last_used[limiter_key] = current_time
|
|
86
|
+
return self._rate_limiters[limiter_key]
|
|
87
|
+
|
|
88
|
+
async def _cleanup_unused_limiters(self) -> None:
|
|
89
|
+
"""Очистка неиспользуемых ограничителей методов с учетом минимального времени жизни."""
|
|
90
|
+
async with self._lock:
|
|
91
|
+
current_time = time.monotonic()
|
|
92
|
+
limiters_to_remove = []
|
|
93
|
+
|
|
94
|
+
for limiter_key in list(self._last_used.keys()):
|
|
95
|
+
last_used = self._last_used[limiter_key]
|
|
96
|
+
last_creation = self._last_instance_creation.get(limiter_key, last_used)
|
|
97
|
+
time_since_creation = current_time - last_creation
|
|
98
|
+
time_since_usage = current_time - last_used
|
|
99
|
+
|
|
100
|
+
if (time_since_usage > self._cleanup_interval and
|
|
101
|
+
time_since_creation > self._min_instance_ttl):
|
|
102
|
+
limiters_to_remove.append(limiter_key)
|
|
103
|
+
|
|
104
|
+
for limiter_key in limiters_to_remove:
|
|
105
|
+
config = self._limiter_configs.pop(limiter_key, None)
|
|
106
|
+
self._rate_limiters.pop(limiter_key, None)
|
|
107
|
+
self._last_used.pop(limiter_key, None)
|
|
108
|
+
self._last_instance_creation.pop(limiter_key, None)
|
|
109
|
+
if config:
|
|
110
|
+
logger.debug(f"Очищен ограничитель для метода {config.method_identifier}")
|
|
111
|
+
|
|
112
|
+
async def _cleanup_loop(self) -> None:
|
|
113
|
+
"""Фоновая задача для очистки неиспользуемых ограничителей."""
|
|
114
|
+
while not self._shutdown:
|
|
115
|
+
try:
|
|
116
|
+
await asyncio.sleep(self._cleanup_interval)
|
|
117
|
+
await self._cleanup_unused_limiters()
|
|
118
|
+
except asyncio.CancelledError:
|
|
119
|
+
break
|
|
120
|
+
except Exception as e:
|
|
121
|
+
logger.error(f"Ошибка в cleanup loop методов: {e}")
|
|
122
|
+
await asyncio.sleep(60)
|
|
123
|
+
|
|
124
|
+
async def get_limiter_stats(self) -> dict[str, dict[str, Any]]:
|
|
125
|
+
"""Формирует статистику по ограничителям методов."""
|
|
126
|
+
current_time = time.monotonic()
|
|
127
|
+
async with self._lock:
|
|
128
|
+
stats = {}
|
|
129
|
+
for limiter_key in self._rate_limiters:
|
|
130
|
+
config = self._limiter_configs.get(limiter_key)
|
|
131
|
+
last_used = self._last_used.get(limiter_key, current_time)
|
|
132
|
+
last_creation = self._last_instance_creation.get(limiter_key, current_time)
|
|
133
|
+
|
|
134
|
+
if config:
|
|
135
|
+
stats[limiter_key] = {
|
|
136
|
+
"config": config,
|
|
137
|
+
"last_used": last_used,
|
|
138
|
+
"last_instance_creation": last_creation,
|
|
139
|
+
"time_since_creation": current_time - last_creation,
|
|
140
|
+
"time_since_usage": current_time - last_used,
|
|
141
|
+
}
|
|
142
|
+
return stats
|
|
143
|
+
|
|
144
|
+
def method_rate_limit(limit_requests: int, interval_seconds: float):
|
|
145
|
+
"""
|
|
146
|
+
Декоратор для применения дополнительных ограничений частоты запросов к методам API.
|
|
147
|
+
|
|
148
|
+
Args:
|
|
149
|
+
limit_requests: Максимальное количество запросов в указанный интервал
|
|
150
|
+
interval_seconds: Временной интервал в секундах
|
|
151
|
+
|
|
152
|
+
Returns:
|
|
153
|
+
Декоратор метода
|
|
154
|
+
"""
|
|
155
|
+
|
|
156
|
+
def decorator(method):
|
|
157
|
+
# Генерируем уникальный идентификатор метода
|
|
158
|
+
method_identifier = f"{method.__module__}.{method.__qualname__}"
|
|
159
|
+
config = MethodRateLimitConfig(
|
|
160
|
+
limit_requests=limit_requests,
|
|
161
|
+
interval_seconds=interval_seconds,
|
|
162
|
+
method_identifier=method_identifier
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
@wraps(method)
|
|
166
|
+
async def wrapper(self, *args, **kwargs):
|
|
167
|
+
# Проверяем, что экземпляр имеет необходимые атрибуты
|
|
168
|
+
if not hasattr(self, '_client_id') or not hasattr(self, '_method_rate_limiter_manager'):
|
|
169
|
+
logger.warning(
|
|
170
|
+
f"Метод {method_identifier} вызван без инициализации ограничителей. "
|
|
171
|
+
"Ограничения не применяются."
|
|
172
|
+
)
|
|
173
|
+
return await method(self, *args, **kwargs)
|
|
174
|
+
|
|
175
|
+
# Дополнительная проверка, что менеджер не None
|
|
176
|
+
if self._method_rate_limiter_manager is None:
|
|
177
|
+
logger.warning(
|
|
178
|
+
f"Менеджер ограничителей методов не инициализирован для {method_identifier}. "
|
|
179
|
+
"Ограничения не применяются."
|
|
180
|
+
)
|
|
181
|
+
return await method(self, *args, **kwargs)
|
|
182
|
+
|
|
183
|
+
# Получаем ограничитель запросов для этого метода
|
|
184
|
+
method_limiter = await self._method_rate_limiter_manager.get_limiter(
|
|
185
|
+
self._client_id, config
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
# Применяем ограничитель запросов
|
|
189
|
+
async with method_limiter:
|
|
190
|
+
logger.debug(
|
|
191
|
+
f"Применен ограничитель метода {method_identifier} для ClientID {self._client_id}: "
|
|
192
|
+
f"{limit_requests} запросов в {interval_seconds} сек"
|
|
193
|
+
)
|
|
194
|
+
return await method(self, *args, **kwargs)
|
|
195
|
+
# Добавляем метаданные
|
|
196
|
+
wrapper._rate_limit_config = config
|
|
197
|
+
return wrapper
|
|
198
|
+
|
|
199
|
+
return decorator
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import time
|
|
3
|
+
from typing import Optional, Any
|
|
4
|
+
|
|
5
|
+
from aiolimiter import AsyncLimiter
|
|
6
|
+
from loguru import logger
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class RateLimiterConfig:
|
|
10
|
+
"""Конфигурация ограничителя кол-ва запросов."""
|
|
11
|
+
|
|
12
|
+
def __init__(
|
|
13
|
+
self,
|
|
14
|
+
max_requests: int = 50,
|
|
15
|
+
time_window: float = 1.0,
|
|
16
|
+
) -> None:
|
|
17
|
+
self.max_requests = max_requests
|
|
18
|
+
self.time_window = time_window
|
|
19
|
+
|
|
20
|
+
def __repr__(self) -> str:
|
|
21
|
+
return f"RateLimiterConfig(max_requests={self.max_requests}, time_window={self.time_window})"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class RateLimiterManager:
|
|
25
|
+
"""
|
|
26
|
+
Менеджер для управления ограничителями запросов по client_id.
|
|
27
|
+
Обеспечивает общий лимит запросов для всех экземпляров с одинаковым client_id.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(self, cleanup_interval: float = 300.0, min_instance_ttl: float = 300.0) -> None:
|
|
31
|
+
self._rate_limiters: dict[str, AsyncLimiter] = {}
|
|
32
|
+
self._instance_refs: dict[str, set[int]] = {}
|
|
33
|
+
self._configs: dict[str, RateLimiterConfig] = {}
|
|
34
|
+
self._last_instance_creation: dict[str, float] = {}
|
|
35
|
+
self._lock = asyncio.Lock()
|
|
36
|
+
self._cleanup_task: Optional[asyncio.Task] = None
|
|
37
|
+
self._shutdown = False
|
|
38
|
+
self._cleanup_interval = cleanup_interval
|
|
39
|
+
self._min_instance_ttl = min_instance_ttl
|
|
40
|
+
|
|
41
|
+
async def start(self) -> None:
|
|
42
|
+
"""Запуск фоновых задач менеджера."""
|
|
43
|
+
if self._cleanup_task is None:
|
|
44
|
+
self._cleanup_task = asyncio.create_task(self._cleanup_loop())
|
|
45
|
+
logger.debug(f"Менеджер ограничителей общих клиентских запросов запущен")
|
|
46
|
+
|
|
47
|
+
async def shutdown(self) -> None:
|
|
48
|
+
"""Корректное завершение работы менеджера."""
|
|
49
|
+
self._shutdown = True
|
|
50
|
+
if self._cleanup_task:
|
|
51
|
+
self._cleanup_task.cancel()
|
|
52
|
+
try:
|
|
53
|
+
await self._cleanup_task
|
|
54
|
+
except asyncio.CancelledError:
|
|
55
|
+
logger.debug("Задача очистки общих ограничителей клиентских запросов отменена")
|
|
56
|
+
self._cleanup_task = None
|
|
57
|
+
|
|
58
|
+
logger.debug("Менеджер общих ограничителей клиентских запросов остановлен")
|
|
59
|
+
|
|
60
|
+
async def get_limiter(self, client_id: str, config: RateLimiterConfig) -> AsyncLimiter:
|
|
61
|
+
"""
|
|
62
|
+
Получает ограничитель кол-ва запросов для указанного client_id.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
client_id: Идентификатор клиента
|
|
66
|
+
config: Конфигурация ограничителя
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
AsyncLimiter: Общий ограничитель для client_id
|
|
70
|
+
"""
|
|
71
|
+
async with self._lock:
|
|
72
|
+
if client_id not in self._rate_limiters:
|
|
73
|
+
limiter = AsyncLimiter(config.max_requests, config.time_window)
|
|
74
|
+
self._rate_limiters[client_id] = limiter
|
|
75
|
+
self._configs[client_id] = config
|
|
76
|
+
if client_id not in self._instance_refs:
|
|
77
|
+
self._instance_refs[client_id] = set()
|
|
78
|
+
if client_id not in self._last_instance_creation:
|
|
79
|
+
self._last_instance_creation[client_id] = time.monotonic()
|
|
80
|
+
logger.debug(f"Инициализирован новый общий ограничитель запросов для ClientID {client_id}: {config}")
|
|
81
|
+
|
|
82
|
+
return self._rate_limiters[client_id]
|
|
83
|
+
|
|
84
|
+
async def register_instance(self, client_id: str, instance_id: int) -> None:
|
|
85
|
+
"""
|
|
86
|
+
Регистрирует экземпляр класса.
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
client_id: Идентификатор клиента
|
|
90
|
+
instance_id: Уникальный идентификатор экземпляра
|
|
91
|
+
"""
|
|
92
|
+
current_time = time.monotonic()
|
|
93
|
+
async with self._lock:
|
|
94
|
+
if client_id not in self._instance_refs:
|
|
95
|
+
self._instance_refs[client_id] = set()
|
|
96
|
+
self._last_instance_creation[client_id] = current_time
|
|
97
|
+
self._instance_refs[client_id].add(instance_id)
|
|
98
|
+
logger.debug(f"Зарегистрировано подключение к API {instance_id} для ClientID {client_id}")
|
|
99
|
+
|
|
100
|
+
async def unregister_instance(self, client_id: str, instance_id: int) -> None:
|
|
101
|
+
"""
|
|
102
|
+
Удаляет регистрацию экземпляра.
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
client_id: Идентификатор клиента
|
|
106
|
+
instance_id: Уникальный идентификатор экземпляра
|
|
107
|
+
"""
|
|
108
|
+
async with self._lock:
|
|
109
|
+
if client_id in self._instance_refs:
|
|
110
|
+
self._instance_refs[client_id].discard(instance_id)
|
|
111
|
+
logger.debug(f"Отменена регистрация подключения к API {instance_id} для ClientID {client_id}")
|
|
112
|
+
|
|
113
|
+
async def _cleanup_unused_limiters(self) -> None:
|
|
114
|
+
"""Очистка неиспользуемых ограничителей кол-ва запросов с учетом минимального времени жизни."""
|
|
115
|
+
async with self._lock:
|
|
116
|
+
current_time = time.monotonic()
|
|
117
|
+
clients_to_remove = []
|
|
118
|
+
|
|
119
|
+
for client_id, instances in self._instance_refs.items():
|
|
120
|
+
if not instances:
|
|
121
|
+
last_creation = self._last_instance_creation.get(client_id, 0)
|
|
122
|
+
time_since_creation = current_time - last_creation
|
|
123
|
+
|
|
124
|
+
# Планируем очистку, если прошло достаточно времени с создания последнего экземпляра
|
|
125
|
+
if time_since_creation > self._min_instance_ttl:
|
|
126
|
+
clients_to_remove.append(client_id)
|
|
127
|
+
|
|
128
|
+
for client_id in clients_to_remove:
|
|
129
|
+
self._rate_limiters.pop(client_id, None)
|
|
130
|
+
self._configs.pop(client_id, None)
|
|
131
|
+
self._instance_refs.pop(client_id, None)
|
|
132
|
+
self._last_instance_creation.pop(client_id, None)
|
|
133
|
+
logger.debug(f"Очищены ресурсы общего ограничителя запросов для ClientID {client_id}")
|
|
134
|
+
|
|
135
|
+
async def _cleanup_loop(self) -> None:
|
|
136
|
+
"""Фоновая задача для очистки неиспользуемых ограничителей кол-ва запросов."""
|
|
137
|
+
while not self._shutdown:
|
|
138
|
+
try:
|
|
139
|
+
await asyncio.sleep(self._cleanup_interval)
|
|
140
|
+
await self._cleanup_unused_limiters()
|
|
141
|
+
except asyncio.CancelledError:
|
|
142
|
+
break
|
|
143
|
+
except Exception as e:
|
|
144
|
+
logger.error(f"Ошибка в cleanup loop: {e}")
|
|
145
|
+
await asyncio.sleep(60) # Пауза при ошибках
|
|
146
|
+
|
|
147
|
+
async def get_active_client_ids(self) -> list[str]:
|
|
148
|
+
"""Формирует список активных client_id."""
|
|
149
|
+
async with self._lock:
|
|
150
|
+
return [cid for cid, instances in self._instance_refs.items() if instances]
|
|
151
|
+
|
|
152
|
+
async def get_instance_stats(self) -> dict[str, int]:
|
|
153
|
+
"""Формирует статистику по экземплярам."""
|
|
154
|
+
async with self._lock:
|
|
155
|
+
return {
|
|
156
|
+
client_id: len(instances)
|
|
157
|
+
for client_id, instances in self._instance_refs.items()
|
|
158
|
+
if instances
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async def get_limiter_stats(self) -> dict[str, dict[str, Any]]:
|
|
162
|
+
"""Формирует детальную статистику по ограничителям."""
|
|
163
|
+
current_time = time.monotonic()
|
|
164
|
+
async with self._lock:
|
|
165
|
+
return {
|
|
166
|
+
client_id: {
|
|
167
|
+
"config": self._configs[client_id],
|
|
168
|
+
"instances": len(self._instance_refs[client_id]),
|
|
169
|
+
"limiter": str(self._rate_limiters[client_id]),
|
|
170
|
+
"last_instance_creation": self._last_instance_creation.get(client_id),
|
|
171
|
+
"time_since_creation": current_time - self._last_instance_creation.get(client_id, current_time),
|
|
172
|
+
}
|
|
173
|
+
for client_id in self._rate_limiters
|
|
174
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from contextlib import asynccontextmanager
|
|
3
|
+
from typing import AsyncIterator
|
|
4
|
+
|
|
5
|
+
from aiohttp import ClientSession, ClientTimeout, TCPConnector
|
|
6
|
+
from loguru import logger
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class SessionManager:
|
|
10
|
+
"""Менеджер для управления HTTP-сессиями."""
|
|
11
|
+
|
|
12
|
+
def __init__(self, timeout: float = 30.0, connector_limit: int = 100) -> None:
|
|
13
|
+
self._sessions: dict[str, ClientSession] = {}
|
|
14
|
+
self._session_refs: dict[str, set[int]] = {}
|
|
15
|
+
self._lock = asyncio.Lock()
|
|
16
|
+
self._timeout = ClientTimeout(total=timeout)
|
|
17
|
+
self._connector_limit = connector_limit
|
|
18
|
+
|
|
19
|
+
@asynccontextmanager
|
|
20
|
+
async def get_session(self, client_id: str, api_key: str, instance_id: int) -> AsyncIterator[ClientSession]:
|
|
21
|
+
"""
|
|
22
|
+
Получает сессию для client_id.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
client_id: Идентификатор клиента
|
|
26
|
+
api_key: Ключ API
|
|
27
|
+
instance_id: ID экземпляра
|
|
28
|
+
|
|
29
|
+
Yields:
|
|
30
|
+
ClientSession: HTTP-сессия
|
|
31
|
+
"""
|
|
32
|
+
async with self._lock:
|
|
33
|
+
if client_id not in self._sessions:
|
|
34
|
+
self._sessions[client_id] = ClientSession(
|
|
35
|
+
headers={
|
|
36
|
+
"Client-Id": client_id,
|
|
37
|
+
"Api-Key": api_key,
|
|
38
|
+
},
|
|
39
|
+
timeout=self._timeout,
|
|
40
|
+
connector=TCPConnector(limit=self._connector_limit)
|
|
41
|
+
)
|
|
42
|
+
self._session_refs[client_id] = set()
|
|
43
|
+
logger.debug(f"Создана новая сессия для ClientID {client_id}")
|
|
44
|
+
|
|
45
|
+
self._session_refs[client_id].add(instance_id)
|
|
46
|
+
|
|
47
|
+
try:
|
|
48
|
+
yield self._sessions[client_id]
|
|
49
|
+
finally:
|
|
50
|
+
async with self._lock:
|
|
51
|
+
if client_id in self._session_refs:
|
|
52
|
+
self._session_refs[client_id].discard(instance_id)
|
|
53
|
+
|
|
54
|
+
async def close_session(self, client_id: str) -> None:
|
|
55
|
+
"""Закрывает сессию для client_id."""
|
|
56
|
+
async with self._lock:
|
|
57
|
+
if client_id in self._sessions:
|
|
58
|
+
if client_id in self._session_refs and self._session_refs[client_id]:
|
|
59
|
+
return
|
|
60
|
+
session = self._sessions.pop(client_id)
|
|
61
|
+
self._session_refs.pop(client_id, None)
|
|
62
|
+
if not session.closed:
|
|
63
|
+
await session.close()
|
|
64
|
+
logger.debug(f"Сессия для ClientID {client_id} закрыта")
|
|
65
|
+
|
|
66
|
+
async def close_all(self) -> None:
|
|
67
|
+
"""Закрывает все сессии."""
|
|
68
|
+
async with self._lock:
|
|
69
|
+
for client_id, session in list(self._sessions.items()):
|
|
70
|
+
if not session.closed:
|
|
71
|
+
await session.close()
|
|
72
|
+
logger.debug(f"Сессия для ClientID {client_id} закрыта")
|
|
73
|
+
self._sessions.clear()
|
|
74
|
+
self._session_refs.clear()
|
|
75
|
+
logger.debug("Все сессии закрыты")
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
__all__ = [
|
|
2
|
+
"SellerBarcodeAPI",
|
|
3
|
+
"SellerCategoryAPI",
|
|
4
|
+
"SellerFBSAPI",
|
|
5
|
+
"SellerPricesAndStocksAPI",
|
|
6
|
+
"SellerProductAPI",
|
|
7
|
+
"SellerWarehouseAPI",
|
|
8
|
+
]
|
|
9
|
+
|
|
10
|
+
from .attributes_and_characteristics import SellerCategoryAPI
|
|
11
|
+
from .barcodes import SellerBarcodeAPI
|
|
12
|
+
from .fbs import SellerFBSAPI
|
|
13
|
+
from .prices_and_stocks import SellerPricesAndStocksAPI
|
|
14
|
+
from .products import SellerProductAPI
|
|
15
|
+
from .warehouses import SellerWarehouseAPI
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
from ..core import APIManager
|
|
2
|
+
from ..schemas.attributes_and_characteristics import (
|
|
3
|
+
DescriptionCategoryAttributeResponse,
|
|
4
|
+
DescriptionCategoryTreeResponse,
|
|
5
|
+
DescriptionCategoryTreeRequest,
|
|
6
|
+
DescriptionCategoryAttributeRequest,
|
|
7
|
+
DescriptionCategoryAttributeValuesRequest,
|
|
8
|
+
DescriptionCategoryAttributeValuesResponse,
|
|
9
|
+
DescriptionCategoryAttributeValuesSearchRequest,
|
|
10
|
+
DescriptionCategoryAttributeValuesSearchResponse
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class SellerCategoryAPI(APIManager):
|
|
15
|
+
"""Реализует методы раздела Атрибуты и характеристики Ozon.
|
|
16
|
+
|
|
17
|
+
References:
|
|
18
|
+
https://docs.ozon.ru/api/seller/?__rr=1#tag/CategoryAPI
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
async def description_category_tree(
|
|
22
|
+
self: "SellerCategoryAPI",
|
|
23
|
+
request: DescriptionCategoryTreeRequest = DescriptionCategoryTreeRequest.model_construct(),
|
|
24
|
+
) -> DescriptionCategoryTreeResponse:
|
|
25
|
+
"""Возвращает категории и типы для товаров в виде дерева.
|
|
26
|
+
Создание товаров доступно только в категориях последнего уровня, сравните именно их с категориями на своей площадке.
|
|
27
|
+
Категории не создаются по запросу пользователя.
|
|
28
|
+
|
|
29
|
+
Notes:
|
|
30
|
+
Внимательно выбирайте категорию для товара: для разных категорий применяется разный размер комиссии.
|
|
31
|
+
|
|
32
|
+
References:
|
|
33
|
+
https://docs.ozon.ru/api/seller/#operation/DescriptionCategoryAPI_GetTree
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
request: Запрос к серверу по схеме `DescriptionCategoryTreeRequest`
|
|
37
|
+
|
|
38
|
+
Returns:
|
|
39
|
+
Категории и типы для товаров в виде дерева по схеме `DescriptionCategoryTreeResponse`
|
|
40
|
+
|
|
41
|
+
Example:
|
|
42
|
+
async with SellerAPI(client_id, api_key) as api:
|
|
43
|
+
description_category_tree = await api.description_category_tree()
|
|
44
|
+
"""
|
|
45
|
+
response = await self._request(
|
|
46
|
+
method="post",
|
|
47
|
+
api_version="v1",
|
|
48
|
+
endpoint="description-category/tree",
|
|
49
|
+
json=request.model_dump()
|
|
50
|
+
)
|
|
51
|
+
return DescriptionCategoryTreeResponse(**response)
|
|
52
|
+
|
|
53
|
+
async def description_category_attribute(
|
|
54
|
+
self: "SellerCategoryAPI",
|
|
55
|
+
request: DescriptionCategoryAttributeRequest,
|
|
56
|
+
) -> DescriptionCategoryAttributeResponse:
|
|
57
|
+
"""Получение характеристик для указанных категории и типа товара.
|
|
58
|
+
Если у `dictionary_id` значение `0`, у атрибута нет вложенных справочников. Если значение другое, то справочники есть.
|
|
59
|
+
Запросите их методом `description_category_attribute_values()`.
|
|
60
|
+
|
|
61
|
+
Notes:
|
|
62
|
+
• `attribute_id` - Идентификатор характеристики, можно получить с помощью метода `description_category_attribute()`
|
|
63
|
+
• `description_category_id` - Идентификатор категории, можно получить с помощью метода `description_category_tree()`
|
|
64
|
+
• `type_id` - Идентификатор типа товара, можно получить с помощью метода `description_category_tree()`
|
|
65
|
+
|
|
66
|
+
References:
|
|
67
|
+
https://docs.ozon.ru/api/seller/#operation/DescriptionCategoryAPI_GetAttributes
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
request: Запрос к серверу gо схеме `DescriptionCategoryAttributeRequest`
|
|
71
|
+
|
|
72
|
+
Returns:
|
|
73
|
+
Характеристики для указанных категории и типа товара по схеме `DescriptionCategoryAttributeResponse`
|
|
74
|
+
|
|
75
|
+
Example:
|
|
76
|
+
async with SellerAPI(client_id, api_key) as api:
|
|
77
|
+
description_category_attribute = await api.description_category_attribute(
|
|
78
|
+
DescriptionCategoryAttributeRequest(
|
|
79
|
+
description_category_id=200000933,
|
|
80
|
+
type_id=93080,
|
|
81
|
+
language=Language.DEFAULT,
|
|
82
|
+
)
|
|
83
|
+
)
|
|
84
|
+
"""
|
|
85
|
+
response = await self._request(
|
|
86
|
+
method="post",
|
|
87
|
+
api_version="v1",
|
|
88
|
+
endpoint="description-category/attribute",
|
|
89
|
+
json=request.model_dump(),
|
|
90
|
+
)
|
|
91
|
+
return DescriptionCategoryAttributeResponse(**response)
|
|
92
|
+
|
|
93
|
+
async def description_category_attribute_values(
|
|
94
|
+
self: "SellerCategoryAPI",
|
|
95
|
+
request: DescriptionCategoryAttributeValuesRequest,
|
|
96
|
+
) -> DescriptionCategoryAttributeValuesResponse:
|
|
97
|
+
"""Возвращает справочник значений характеристики.
|
|
98
|
+
Узнать, есть ли вложенный справочник, можно через метод `description_category_attribute()`.
|
|
99
|
+
|
|
100
|
+
Notes:
|
|
101
|
+
• `attribute_id` - Идентификатор характеристики, можно получить с помощью метода `description_category_attribute()`
|
|
102
|
+
• `description_category_id` - Идентификатор категории, можно получить с помощью метода `description_category_tree()`
|
|
103
|
+
• `type_id` - Идентификатор типа товара, можно получить с помощью метода `description_category_tree()`
|
|
104
|
+
• Для пагинации используйте значение `last_value_id`
|
|
105
|
+
|
|
106
|
+
References:
|
|
107
|
+
https://docs.ozon.ru/api/seller/?__rr=2&abt_att=1#operation/DescriptionCategoryAPI_GetAttributeValues
|
|
108
|
+
|
|
109
|
+
Args:
|
|
110
|
+
request: Запрос к серверу по схеме `DescriptionCategoryAttributeValuesRequest`
|
|
111
|
+
|
|
112
|
+
Returns:
|
|
113
|
+
Cправочник значений характеристики по схеме `DescriptionCategoryAttributeValuesResponse`
|
|
114
|
+
|
|
115
|
+
Example:
|
|
116
|
+
async with SellerAPI(client_id, api_key) as api:
|
|
117
|
+
description_category_attribute_values = await api.description_category_attribute_values(
|
|
118
|
+
DescriptionCategoryAttributeValuesRequest(
|
|
119
|
+
attribute_id=85,
|
|
120
|
+
description_category_id=17054869,
|
|
121
|
+
language=Language.DEFAULT,
|
|
122
|
+
last_value_id=0,
|
|
123
|
+
limit=100,
|
|
124
|
+
type_id=97311
|
|
125
|
+
)
|
|
126
|
+
)
|
|
127
|
+
"""
|
|
128
|
+
response = await self._request(
|
|
129
|
+
method="post",
|
|
130
|
+
api_version="v1",
|
|
131
|
+
endpoint="description-category/attribute/values",
|
|
132
|
+
json=request.model_dump(),
|
|
133
|
+
)
|
|
134
|
+
return DescriptionCategoryAttributeValuesResponse(**response)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
async def description_category_attribute_values_search(
|
|
138
|
+
self: "SellerCategoryAPI",
|
|
139
|
+
request: DescriptionCategoryAttributeValuesSearchRequest,
|
|
140
|
+
) -> DescriptionCategoryAttributeValuesSearchResponse:
|
|
141
|
+
"""Возвращает справочные значения характеристики по заданному значению value в запросе.
|
|
142
|
+
Узнать, есть ли вложенный справочник, можно через метод `description_category_attribute()`.
|
|
143
|
+
|
|
144
|
+
Notes:
|
|
145
|
+
• `attribute_id` - Идентификатор характеристики, можно получить с помощью метода `description_category_attribute()`
|
|
146
|
+
• `description_category_id` - Идентификатор категории, можно получить с помощью метода `description_category_tree()`
|
|
147
|
+
• `type_id` - Идентификатор типа товара, можно получить с помощью метода `description_category_tree()`
|
|
148
|
+
• `value` - Поисковый запрос (минимум 2 символа)
|
|
149
|
+
|
|
150
|
+
References:
|
|
151
|
+
https://docs.ozon.ru/api/seller/?__rr=2&abt_att=1#operation/DescriptionCategoryAPI_SearchAttributeValues
|
|
152
|
+
|
|
153
|
+
Args:
|
|
154
|
+
request: Запрос к серверу по схеме `DescriptionCategoryAttributeValuesSearchRequest`
|
|
155
|
+
|
|
156
|
+
Returns:
|
|
157
|
+
Справочные значения характеристики по заданному значению value в запросе по схеме `DescriptionCategoryAttributeValuesSearchResponse`
|
|
158
|
+
|
|
159
|
+
Example:
|
|
160
|
+
async with SellerAPI(client_id, api_key) as api:
|
|
161
|
+
description_category_attribute_values = await api.description_category_attribute_values_search(
|
|
162
|
+
DescriptionCategoryAttributeValuesSearchRequest(
|
|
163
|
+
attribute_id=85,
|
|
164
|
+
description_category_id=17054869,
|
|
165
|
+
limit=100,
|
|
166
|
+
type_id=97311,
|
|
167
|
+
value="Красота"
|
|
168
|
+
)
|
|
169
|
+
)
|
|
170
|
+
"""
|
|
171
|
+
response = await self._request(
|
|
172
|
+
method="post",
|
|
173
|
+
api_version="v1",
|
|
174
|
+
endpoint="description-category/attribute/values/search",
|
|
175
|
+
json=request.model_dump(),
|
|
176
|
+
)
|
|
177
|
+
return DescriptionCategoryAttributeValuesSearchResponse(**response)
|