python-wb 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.
@@ -0,0 +1,185 @@
1
+ Metadata-Version: 2.4
2
+ Name: python-wb
3
+ Version: 0.1.0
4
+ Summary: Библиотека для работы с API Wildberries на Python.
5
+ Author-email: MistakeTZ <mistaketz@gmail.com>
6
+ Project-URL: Homepage, https://github.com/mistaketz/pywb
7
+ Project-URL: Bug Tracker, https://github.com/mistaketz/pywb/issues
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Operating System :: OS Independent
11
+ Requires-Python: >=3.12
12
+ Description-Content-Type: text/markdown
13
+ License-File: LICENSE
14
+ Requires-Dist: aiohttp>=3.13.5
15
+ Requires-Dist: build>=1.4.2
16
+ Requires-Dist: httpx>=0.28.1
17
+ Requires-Dist: pydantic>=2.12.5
18
+ Requires-Dist: setuptools>=82.0.1
19
+ Dynamic: license-file
20
+
21
+ ## pywb
22
+
23
+ Asynchronous Python client for working with the Wildberries Seller API.
24
+
25
+ The library provides:
26
+
27
+ - async HTTP client based on `aiohttp`
28
+ - typed request/response models via `pydantic`
29
+ - centralized API error mapping to Python exceptions
30
+ - support for multiple Wildberries API domains
31
+
32
+ ## Features
33
+
34
+ - Easy entry point through `WBClient`
35
+ - Domain routing (`common`, `content`, `statistics`, etc.)
36
+ - Built-in methods:
37
+ - ping (`client.ping()`)
38
+ - content ping (`client.ping_content()`)
39
+ - statistics orders report (`client.get_orders(...)`)
40
+ - etc.
41
+ - Generic low-level method execution:
42
+ - `await client(SomeWBMethod(...))`
43
+ - Context manager support:
44
+ - `async with WBClient(...) as client:`
45
+
46
+ ## Requirements
47
+
48
+ - Python 3.12+
49
+
50
+ ## Installation
51
+
52
+ Using `uv`:
53
+
54
+ ```bash
55
+ uv add python-wb
56
+ ```
57
+
58
+ With `pip`:
59
+
60
+ ```bash
61
+ pip install python-wb
62
+ ```
63
+
64
+ ## Quick Start
65
+
66
+ ```python
67
+ import asyncio
68
+ from pywb import WBClient
69
+
70
+
71
+ async def main() -> None:
72
+ token = "YOUR_WB_API_TOKEN"
73
+
74
+ async with WBClient(token) as client:
75
+ result = await client.ping()
76
+ print(result.ts, result.status)
77
+
78
+
79
+ if __name__ == "__main__":
80
+ asyncio.run(main())
81
+ ```
82
+
83
+ ## Usage
84
+
85
+ ### 1) Health check
86
+
87
+ ```python
88
+ result = await client.ping()
89
+ print(result.status)
90
+ ```
91
+
92
+ ### 2) Content API health check
93
+
94
+ ```python
95
+ result = await client.ping_content()
96
+ print(result.status)
97
+ ```
98
+
99
+ ### 3) Get orders from Statistics API
100
+
101
+ ```python
102
+ from datetime import datetime
103
+
104
+ orders = await client.get_orders(
105
+ date_from=datetime(2026, 1, 1, 0, 0, 0),
106
+ flag=0,
107
+ )
108
+
109
+ if orders:
110
+ print(orders[0].srid, orders[0].brand)
111
+ ```
112
+
113
+ `date_from` accepts either:
114
+
115
+ - ISO 8601 string (example: `"2022-03-04T18:08:31"`)
116
+ - `datetime` object
117
+
118
+ ## Low-Level Method Call
119
+
120
+ You can call method objects directly through the client:
121
+
122
+ ```python
123
+ from pywb.methods import Ping
124
+
125
+ response = await client(Ping())
126
+ ```
127
+
128
+ This is useful when adding new method classes while keeping one transport layer.
129
+
130
+ ## Error Handling
131
+
132
+ HTTP errors are mapped to dedicated exceptions:
133
+
134
+ - `BadRequestError` (400)
135
+ - `UnauthorizedError` (401)
136
+ - `PaymentRequiredError` (402)
137
+ - `AccessDeniedError` (403)
138
+ - `NotFoundError` (404)
139
+ - `ConflictError` (409)
140
+ - `PayloadTooLargeError` (413)
141
+ - `UnprocessableEntityError` (422)
142
+ - `TooManyRequestsError` (429)
143
+ - `InternalServerError` (5xx)
144
+
145
+ Base type: `WBApiError`
146
+
147
+ Example:
148
+
149
+ ```python
150
+ from pywb.exceptions import BadRequestError
151
+
152
+ try:
153
+ await client.get_orders(date_from="2022-03-04T18:08:31")
154
+ except BadRequestError as e:
155
+ print(e)
156
+ print(e.payload)
157
+ ```
158
+
159
+ Network/transport failures in the `aiohttp` session are raised as `WBNetworkError`.
160
+
161
+ ## Sandbox and Domains
162
+
163
+ The client supports domain-based URL routing through `WBDomain` and `WB_ROUTER`.
164
+
165
+ To enable sandbox mode (only where available for a domain):
166
+
167
+ ```python
168
+ client = WBClient(token="YOUR_TOKEN", is_sandbox=True)
169
+ ```
170
+
171
+ If sandbox is unavailable for a domain, a `ValueError` is raised by the session router.
172
+
173
+ ## Development
174
+
175
+ Run the example script:
176
+
177
+ ```bash
178
+ python examples/ping.py
179
+ ```
180
+
181
+ ## Notes
182
+
183
+ - Keep your API token secret and do not commit it to git.
184
+ - Respect Wildberries API rate limits for each endpoint.
185
+ - For the statistics orders endpoint, use pagination strategy based on the last record timestamp when handling large datasets.
@@ -0,0 +1,25 @@
1
+ test.py,sha256=Swoh8k3MnJNbc9uWPPKNHmnawy6bJU6yelmWdPeOwJw,845
2
+ python_wb-0.1.0.dist-info/licenses/LICENSE,sha256=ZVCm8rowdh_H4PlGUkYZ3KUvwsgdfWcmBTgpsuwT-3I,1072
3
+ pywb/__init__.py,sha256=Zbw-zByB-HJO3LCx8f0dqaUXTSu2LYjFntXlOTgecIk,200
4
+ pywb/__meta__.py,sha256=sXLh7g3KC4QCFxcZGBTpG2scR7hmmBsMjq6LqRptkRg,22
5
+ pywb/exceptions.py,sha256=SvWSGh6GO4Qszf3pBmj4VppV-syltkzayhr8WDJeZhI,1961
6
+ pywb/client/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
+ pywb/client/wb_client.py,sha256=RfoANIt8Y0nSd_VTNrvujNGu9EJhZQsG11juK5gXLvY,5378
8
+ pywb/client/session/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
9
+ pywb/client/session/aiohttp.py,sha256=tAMIx3NCkKVmYawUiwLq2JAJpZXSBINS915cCQSjGb4,4595
10
+ pywb/client/session/base.py,sha256=7R8KtmemOtvb8uxaBUMmwfhSAyp_w3FI3zDXwS14vXU,6371
11
+ pywb/enums/__init__.py,sha256=Fc9c2c9HoGz-yVf1r1x6_pZSFbLxBgsaBcNGv2aSMuM,116
12
+ pywb/enums/urls.py,sha256=FKwmJFno_viYE0LGJLpzP-9dJ_1Ymp7aP1m_5F4gOGI,2079
13
+ pywb/methods/__init__.py,sha256=pJcfhaujOVlKVK_YvWEi4HyGI_3AdmlL3RV5TSdVhpk,234
14
+ pywb/methods/base.py,sha256=77ZkL3Yl0PlBbDTGkJD5ggl1nWhDm3IdfZJE4RREIz4,416
15
+ pywb/methods/ping.py,sha256=Uce67ZgZ4ZPc9O9dw5txTP04WJJSHpAjEeBeipRdlyw,586
16
+ pywb/methods/statistics.py,sha256=UTMXKmGrcoCcB0IasHUlKwPKB4zPKU4CmTufHq6TIoI,530
17
+ pywb/methods/update_product_card.py,sha256=fsIHhTzlT12t-hmjWrZVDR9Gj-r4-0_py9A-9vxuzP4,372
18
+ pywb/types/__init__.py,sha256=TEubPAI_bqgqC2Ron_62YHZoxt0TiDDigd_B-fOrG0g,131
19
+ pywb/types/order.py,sha256=hJSBLPVoXouy4seo3fh3-L_hqqo_KTVXqt2qAXxpoPg,1316
20
+ pywb/types/ping_response.py,sha256=SIpcGZBWx0GNbNt23XqPBzH-4AIUxXmbK4PQGyC4wt0,143
21
+ pywb/utils/errors.py,sha256=6fPTI7XvAyI8f53xl329aDemUaHBnmdRNEUSVrt1CgU,1131
22
+ python_wb-0.1.0.dist-info/METADATA,sha256=7Fghwm6MxRNeIKtv4iC_nRpYQ7_5rHJqb-WCI_1EMm4,3967
23
+ python_wb-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
24
+ python_wb-0.1.0.dist-info/top_level.txt,sha256=ga0M4jXex5QccfVXqDbGMV3mmIaKvNg1HLOGclNqytE,10
25
+ python_wb-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 Browser Use Inc.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,2 @@
1
+ pywb
2
+ test
pywb/__init__.py ADDED
@@ -0,0 +1,12 @@
1
+ from . import enums, methods, types
2
+ from .__meta__ import __version__
3
+ from .client.wb_client import WBClient
4
+
5
+
6
+ __all__ = (
7
+ "WBClient",
8
+ "enums",
9
+ "methods",
10
+ "types",
11
+ "__version__",
12
+ )
pywb/__meta__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.0.1"
File without changes
File without changes
@@ -0,0 +1,131 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import ssl
5
+ from typing import TYPE_CHECKING, Any, cast
6
+
7
+ import certifi
8
+ from aiohttp import ClientError, ClientSession, TCPConnector
9
+ from typing_extensions import Self
10
+
11
+ from .base import BaseSession, WBType
12
+ from ...enums import WB_ROUTER, WBDomain
13
+ from ...exceptions import WBApiError
14
+
15
+ if TYPE_CHECKING:
16
+ from ...methods.base import WBMethod
17
+
18
+
19
+ class WBNetworkError(WBApiError):
20
+ """Ошибка сети (таймаут, обрыв соединения, DNS и т.д.)"""
21
+
22
+ def __init__(self, message: str, original_exception: Exception | None = None):
23
+ super().__init__(
24
+ status_code=0, payload={"title": "Network Error", "detail": message}
25
+ )
26
+ self.original_exception = original_exception
27
+
28
+
29
+ class AiohttpWBSession(BaseSession):
30
+ """
31
+ HTTP-сессия на базе aiohttp для работы с Wildberries API.
32
+ """
33
+
34
+ def __init__(self, is_sandbox: bool = False, limit: int = 100, **kwargs):
35
+ """
36
+ :param limit: Максимальное количество одновременных соединений (Connection Pooling).
37
+ :param kwargs: Аргументы для BaseSession (base_url, timeout и т.д.).
38
+ """
39
+ super().__init__(**kwargs)
40
+
41
+ self.is_sandbox = is_sandbox
42
+ self._session: ClientSession | None = None
43
+ self._connector_type: type[TCPConnector] = TCPConnector
44
+ self._connector_init: dict[str, Any] = {
45
+ "ssl": ssl.create_default_context(cafile=certifi.where()),
46
+ "limit": limit,
47
+ "ttl_dns_cache": 3600,
48
+ }
49
+
50
+ async def create_session(self) -> ClientSession:
51
+ """Ленивая инициализация aiohttp сессии."""
52
+ if self._session is None or self._session.closed:
53
+ self._session = ClientSession(
54
+ connector=self._connector_type(**self._connector_init),
55
+ headers={"Content-Type": "application/json"},
56
+ )
57
+ return self._session
58
+
59
+ async def close(self) -> None:
60
+ """Аккуратное закрытие сессии и всех TCP соединений."""
61
+ if self._session is not None and not self._session.closed:
62
+ await self._session.close()
63
+
64
+ await asyncio.sleep(0.25)
65
+
66
+ def _get_url(self, domain: WBDomain, path: str) -> str:
67
+ """Определяет базовый URL на основе домена и флага Sandbox"""
68
+ domain_urls = WB_ROUTER[domain]
69
+
70
+ if self.is_sandbox:
71
+ if domain_urls["sandbox"] is None:
72
+ raise ValueError(
73
+ f"Sandbox environment is not available for domain: {domain}"
74
+ )
75
+ base_url = domain_urls["sandbox"]
76
+ else:
77
+ base_url = domain_urls["prod"]
78
+
79
+ return f"{base_url.rstrip('/')}/{path.lstrip('/')}"
80
+
81
+ async def make_request(
82
+ self,
83
+ token: str,
84
+ method: WBMethod[WBType],
85
+ timeout: int | None = None,
86
+ ) -> WBType:
87
+ """
88
+ Подготавливает данные, выполняет запрос и передает результат валидатору.
89
+ """
90
+ session = await self.create_session()
91
+
92
+ url = self._get_url(method.__domain__, method.__api_path__)
93
+
94
+ http_method = method.__http_method__.upper()
95
+
96
+ raw_payload = method.model_dump(exclude_none=True)
97
+
98
+ prepared_payload = self.prepare_value(raw_payload)
99
+
100
+ request_kwargs: dict[str, Any] = {}
101
+ if http_method in ("GET", "DELETE"):
102
+ request_kwargs["params"] = prepared_payload
103
+ else:
104
+ request_kwargs["json"] = prepared_payload
105
+
106
+ try:
107
+ async with session.request(
108
+ method=http_method,
109
+ url=url,
110
+ headers={"Authorization": token},
111
+ timeout=self.timeout if timeout is None else timeout,
112
+ **request_kwargs,
113
+ ) as resp:
114
+ raw_result = await resp.text()
115
+
116
+ except asyncio.TimeoutError as e:
117
+ raise WBNetworkError("Request timeout error", e) from e
118
+ except ClientError as e:
119
+ raise WBNetworkError(f"Network error: {type(e).__name__} - {e}", e) from e
120
+
121
+ response_data = self.check_response(
122
+ method=method,
123
+ status_code=resp.status,
124
+ content=raw_result,
125
+ )
126
+
127
+ return cast(WBType, response_data)
128
+
129
+ async def __aenter__(self) -> Self:
130
+ await self.create_session()
131
+ return self
@@ -0,0 +1,185 @@
1
+ from __future__ import annotations
2
+
3
+ import abc
4
+ import datetime
5
+ import json
6
+ from enum import Enum
7
+ from typing import TYPE_CHECKING, Any, Callable, TypeVar, cast
8
+
9
+ from pydantic import BaseModel, TypeAdapter, ValidationError
10
+ from typing_extensions import Self
11
+
12
+ from ...exceptions import (
13
+ AccessDeniedError,
14
+ BadRequestError,
15
+ ConflictError,
16
+ InternalServerError,
17
+ NotFoundError,
18
+ PaymentRequiredError,
19
+ PayloadTooLargeError,
20
+ TooManyRequestsError,
21
+ UnauthorizedError,
22
+ UnprocessableEntityError,
23
+ WBApiError,
24
+ )
25
+
26
+ if TYPE_CHECKING:
27
+ from types import TracebackType
28
+ from ...methods import WBMethod
29
+
30
+ WBType = TypeVar("WBType")
31
+
32
+ _JsonLoads = Callable[..., Any]
33
+ _JsonDumps = Callable[..., str]
34
+
35
+ DEFAULT_TIMEOUT: float = 60.0
36
+ WB_PRODUCTION_API: str = "https://common-api.wildberries.ru"
37
+
38
+
39
+ class ClientDecodeError(Exception):
40
+ """Ошибка при парсинге или валидации ответа от серверов WB"""
41
+
42
+ def __init__(self, message: str, original: Exception, data: Any):
43
+ super().__init__(message)
44
+ self.original = original
45
+ self.data = data
46
+
47
+
48
+ class BaseSession(abc.ABC):
49
+ """
50
+ Базовый класс для всех HTTP сессий клиента Wildberries.
51
+ Наследуйтесь от него, чтобы реализовать конкретную сессию (например, HttpxSession).
52
+ """
53
+
54
+ def __init__(
55
+ self,
56
+ base_url: str = WB_PRODUCTION_API,
57
+ json_loads: _JsonLoads = json.loads,
58
+ json_dumps: _JsonDumps = json.dumps,
59
+ timeout: float = DEFAULT_TIMEOUT,
60
+ ) -> None:
61
+ self.base_url = base_url
62
+ self.json_loads = json_loads
63
+ self.json_dumps = json_dumps
64
+ self.timeout = timeout
65
+
66
+ def check_response(
67
+ self,
68
+ method: WBMethod[WBType],
69
+ status_code: int,
70
+ content: str,
71
+ ) -> WBType:
72
+ """
73
+ Проверяет статус ответа и десериализует его в нужный тип Pydantic.
74
+ """
75
+ if status_code == 204:
76
+ return cast(WBType, None)
77
+
78
+ try:
79
+ json_data = self.json_loads(content) if content else {}
80
+ except Exception as e:
81
+ raise ClientDecodeError("Failed to decode JSON response", e, content) from e
82
+
83
+ if status_code >= 400:
84
+ self._raise_for_status(status_code, json_data)
85
+
86
+ return_type = method.__returning__
87
+
88
+ if return_type in (bool, dict, list, Any):
89
+ return cast(WBType, json_data)
90
+
91
+ try:
92
+ adapter = TypeAdapter(return_type)
93
+ validated_data = adapter.validate_python(json_data)
94
+ return cast(WBType, validated_data)
95
+ except ValidationError as e:
96
+ raise ClientDecodeError("Failed to deserialize response into Pydantic model", e, json_data) from e
97
+
98
+ def _raise_for_status(self, status_code: int, payload: dict[str, Any]) -> None:
99
+ """Внутренний маппинг ошибок HTTP на исключения Python"""
100
+ if status_code == 400:
101
+ raise BadRequestError(status_code, payload)
102
+ if status_code == 401:
103
+ raise UnauthorizedError(status_code, payload)
104
+ if status_code == 402:
105
+ raise PaymentRequiredError(status_code, payload)
106
+ if status_code == 403:
107
+ raise AccessDeniedError(status_code, payload)
108
+ if status_code == 404:
109
+ raise NotFoundError(status_code, payload)
110
+ if status_code == 409:
111
+ raise ConflictError(status_code, payload)
112
+ if status_code == 413:
113
+ raise PayloadTooLargeError(status_code, payload)
114
+ if status_code == 422:
115
+ raise UnprocessableEntityError(status_code, payload)
116
+ if status_code == 429:
117
+ raise TooManyRequestsError(status_code, payload)
118
+ if status_code >= 500:
119
+ raise InternalServerError(status_code, payload)
120
+
121
+ raise WBApiError(status_code, payload)
122
+
123
+ def prepare_value(self, value: Any) -> Any:
124
+ """
125
+ Подготавливает значения перед отправкой в WB API.
126
+ В отличие от Telegram, WB требует строгий JSON, поэтому
127
+ в основном мы форматируем даты и Enums.
128
+ """
129
+ if value is None:
130
+ return None
131
+ if isinstance(value, str):
132
+ return value
133
+ if isinstance(value, datetime.datetime):
134
+ # WB обычно ожидает ISO 8601 (например "2024-09-30T06:52:38Z")
135
+ return value.isoformat() + "Z"
136
+ if isinstance(value, Enum):
137
+ return value.value
138
+ if isinstance(value, BaseModel):
139
+ return self.prepare_value(value.model_dump(exclude_none=True))
140
+ if isinstance(value, dict):
141
+ return {k: self.prepare_value(v) for k, v in value.items() if v is not None}
142
+ if isinstance(value, list):
143
+ return [self.prepare_value(v) for v in value if v is not None]
144
+
145
+ return value
146
+
147
+ @abc.abstractmethod
148
+ async def close(self) -> None:
149
+ """Закрытие HTTP сессии (aiohttp/httpx)"""
150
+
151
+ @abc.abstractmethod
152
+ async def make_request(
153
+ self,
154
+ token: str,
155
+ method: WBMethod[WBType],
156
+ timeout: int | None = None,
157
+ ) -> WBType:
158
+ """
159
+ Фактическое выполнение HTTP запроса.
160
+ Должно быть реализовано в наследнике (HttpxSession или AiohttpSession).
161
+ """
162
+
163
+ async def __call__(
164
+ self,
165
+ token: str,
166
+ method: WBMethod[WBType],
167
+ timeout: int | None = None,
168
+ ) -> WBType:
169
+ """
170
+ Точка входа для выполнения запроса.
171
+ """
172
+ # TODO: Здесь можно добавить мидлвари (логирование, ретраи, метрики) перед вызовом make_request
173
+
174
+ return await self.make_request(token, method, timeout=timeout)
175
+
176
+ async def __aenter__(self) -> Self:
177
+ return self
178
+
179
+ async def __aexit__(
180
+ self,
181
+ exc_type: type[BaseException] | None,
182
+ exc_value: BaseException | None,
183
+ traceback: TracebackType | None,
184
+ ) -> None:
185
+ await self.close()
@@ -0,0 +1,122 @@
1
+ # client.py
2
+ from __future__ import annotations
3
+ from datetime import datetime
4
+ from typing import TYPE_CHECKING, Optional
5
+
6
+ from pywb.methods.ping import PingContent
7
+ from pywb.methods.statistics import GetOrders
8
+ from pywb.types.order import StatisticOrder
9
+
10
+ from .session.base import BaseSession
11
+ from .session.aiohttp import AiohttpWBSession
12
+
13
+ from ..methods import Ping, WT
14
+ from ..types import PingResponse
15
+
16
+ if TYPE_CHECKING:
17
+ from ..methods.base import WBMethod
18
+
19
+
20
+ class WBClient:
21
+ """
22
+ Асинхронный клиент для работы с API Wildberries.
23
+ """
24
+
25
+ def __init__(
26
+ self,
27
+ token: str,
28
+ is_sandbox: bool = False,
29
+ session: BaseSession | None = None,
30
+ ) -> None:
31
+ self.token = token
32
+
33
+ if session is None:
34
+ session = AiohttpWBSession(is_sandbox=is_sandbox)
35
+
36
+ self.session = session
37
+
38
+ async def __call__(
39
+ self, method: WBMethod[WT], request_timeout: int | None = None
40
+ ) -> WT:
41
+ """
42
+ Единая точка входа.
43
+ Вызывает метод API и возвращает результат нужного типа.
44
+ """
45
+ return await self.session(
46
+ token=self.token, method=method, timeout=request_timeout
47
+ )
48
+
49
+ async def aclose(self) -> None:
50
+ """Делегируем закрытие соединений сессии"""
51
+ await self.session.close()
52
+
53
+ async def __aenter__(self):
54
+ return self
55
+
56
+ async def __aexit__(self, exc_type, exc_val, exc_tb):
57
+ await self.aclose()
58
+
59
+ async def ping(self, request_timeout: int | None = None) -> PingResponse:
60
+ """
61
+ Проверяет доступность серверов Wildberries и валидность токена.
62
+ """
63
+ call = Ping()
64
+
65
+ return await self(call, request_timeout=request_timeout)
66
+
67
+ async def ping_content(self, request_timeout: int | None = None) -> PingResponse:
68
+ """
69
+ Специальный метод для проверки доступности Content API (для карточек, цен и т.д.).
70
+ Полезно, если у вас есть методы, которые работают только с этим доменом.
71
+ """
72
+ call = PingContent()
73
+
74
+ return await self(call, request_timeout=request_timeout)
75
+
76
+ async def get_orders(
77
+ self,
78
+ date_from: datetime | str,
79
+ flag: int = 0,
80
+ request_timeout: Optional[int] = None
81
+ ) -> list[StatisticOrder]:
82
+ """
83
+ Возвращает информацию о заказах.
84
+ Данные в этом отчете предварительные и используются для оперативного контроля.
85
+
86
+ ⚠️ ЛИМИТЫ И ПРАВИЛА ИСПОЛЬЗОВАНИЯ API:
87
+ ---------------------------------------
88
+ - Обновление данных: Каждые 30 минут.
89
+ - Хранение данных: Гарантируется не более 90 дней со дня продажи.
90
+ - Rate Limit: 1 запрос в 1 минуту на один аккаунт продавца (Burst: 1 запрос).
91
+
92
+ ОСОБЕННОСТИ ПАГИНАЦИИ (Лимит строк):
93
+ ---------------------------------------
94
+ При запросе с flag=0 или без него установлен условный лимит в 80 000 строк.
95
+ Для получения всех заказов:
96
+ 1. В первом запросе передайте начальную дату в `date_from`.
97
+ 2. Если вернулось 80 000 строк, возьмите значение `last_change_date`
98
+ из ПОСЛЕДНЕЙ строки ответа и передайте его как `date_from` в следующий запрос.
99
+ 3. Если ответ вернул пустой массив `[]` — все заказы получены.
100
+
101
+ ПРИМЕЧАНИЯ:
102
+ ---------------------------------------
103
+ - 1 строка = 1 заказ = 1 единица товара.
104
+ - `srid` — уникальный идентификатор заказа.
105
+ - В отчет НЕ попадают заказы без подтвержденной оплаты (например, рассрочка).
106
+ Такие продажи можно найти в детализации отчета о реализации.
107
+
108
+ :param date_from: Дата в формате ISO 8601 (например, "2022-03-04T18:08:31")
109
+ или объект datetime.
110
+ :param flag: 0 (по умолчанию) - получить данные, у которых lastChangeDate >= dateFrom.
111
+ 1 - получить данные, у которых date >= dateFrom.
112
+ :param request_timeout: Таймаут запроса в секундах.
113
+ :return: Список объектов StatisticOrder.
114
+ """
115
+
116
+ if isinstance(date_from, datetime):
117
+ date_from_str = date_from.replace(microsecond=0).isoformat()
118
+ else:
119
+ date_from_str = date_from
120
+
121
+ call = GetOrders(dateFrom=date_from_str, flag=flag)
122
+ return await self(call, request_timeout=request_timeout)
pywb/enums/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ from .urls import WBDomain, DomainUrls, WB_ROUTER
2
+
3
+ __all__ = [
4
+ "WBDomain",
5
+ "DomainUrls",
6
+ "WB_ROUTER",
7
+ ]
pywb/enums/urls.py ADDED
@@ -0,0 +1,39 @@
1
+ from enum import Enum
2
+ from typing import Dict, TypedDict
3
+
4
+ class WBDomain(str, Enum):
5
+ CONTENT = "content"
6
+ ANALYTICS = "analytics"
7
+ PRICES = "prices"
8
+ MARKETPLACE = "marketplace"
9
+ STATISTICS = "statistics"
10
+ PROMOTION = "promotion"
11
+ FEEDBACKS = "feedbacks"
12
+ BUYER_CHAT = "buyer_chat"
13
+ SUPPLIES = "supplies"
14
+ RETURNS = "returns"
15
+ DOCUMENTS = "documents"
16
+ FINANCE = "finance"
17
+ COMMON = "common"
18
+ USER_MANAGEMENT = "user_management"
19
+
20
+ class DomainUrls(TypedDict):
21
+ prod: str
22
+ sandbox: str | None
23
+
24
+ WB_ROUTER: Dict[WBDomain, DomainUrls] = {
25
+ WBDomain.CONTENT: {"prod": "https://content-api.wildberries.ru", "sandbox": "https://content-api-sandbox.wildberries.ru"},
26
+ WBDomain.ANALYTICS: {"prod": "https://seller-analytics-api.wildberries.ru", "sandbox": None},
27
+ WBDomain.PRICES: {"prod": "https://discounts-prices-api.wildberries.ru", "sandbox": "https://discounts-prices-api-sandbox.wildberries.ru"},
28
+ WBDomain.MARKETPLACE: {"prod": "https://marketplace-api.wildberries.ru", "sandbox": None},
29
+ WBDomain.STATISTICS: {"prod": "https://statistics-api.wildberries.ru", "sandbox": "https://statistics-api-sandbox.wildberries.ru"},
30
+ WBDomain.PROMOTION: {"prod": "https://advert-api.wildberries.ru", "sandbox": "https://advert-api-sandbox.wildberries.ru"},
31
+ WBDomain.FEEDBACKS: {"prod": "https://feedbacks-api.wildberries.ru", "sandbox": "https://feedbacks-api-sandbox.wildberries.ru"},
32
+ WBDomain.BUYER_CHAT: {"prod": "https://buyer-chat-api.wildberries.ru", "sandbox": None},
33
+ WBDomain.SUPPLIES: {"prod": "https://supplies-api.wildberries.ru", "sandbox": None},
34
+ WBDomain.RETURNS: {"prod": "https://returns-api.wildberries.ru", "sandbox": None},
35
+ WBDomain.DOCUMENTS: {"prod": "https://documents-api.wildberries.ru", "sandbox": None},
36
+ WBDomain.FINANCE: {"prod": "https://finance-api.wildberries.ru", "sandbox": None},
37
+ WBDomain.COMMON: {"prod": "https://common-api.wildberries.ru", "sandbox": None},
38
+ WBDomain.USER_MANAGEMENT: {"prod": "https://user-management-api.wildberries.ru", "sandbox": None},
39
+ }
pywb/exceptions.py ADDED
@@ -0,0 +1,83 @@
1
+ from typing import Optional, Dict, Any
2
+
3
+
4
+ class WBApiError(Exception):
5
+ """
6
+ Base exception for all Wildberries API errors.
7
+ Automatically parses the WB error JSON schema to provide clear error messages.
8
+ """
9
+
10
+ def __init__(self, status_code: int, payload: Optional[Dict[str, Any]] = None):
11
+ self.status_code = status_code
12
+ self.payload = payload or {}
13
+
14
+ self.title = self.payload.get("title", "Unknown API Error")
15
+ self.detail = self.payload.get("detail", "")
16
+ self.timestamp = self.payload.get("timestamp", "")
17
+ self.status_text = self.payload.get("statusText", "")
18
+
19
+ message = f"[{self.status_code}] {self.title}"
20
+ if self.detail:
21
+ message += f" | Detail: {self.detail}"
22
+
23
+ super().__init__(message)
24
+
25
+
26
+ class BadRequestError(WBApiError):
27
+ """400: Bad request. Check the request syntax."""
28
+
29
+ pass
30
+
31
+
32
+ class UnauthorizedError(WBApiError):
33
+ """401: Unauthorized. Token is missing, expired, or incorrect."""
34
+
35
+ pass
36
+
37
+
38
+ class PaymentRequiredError(WBApiError):
39
+ """402: Payment required. Insufficient funds on the Catalog balance."""
40
+
41
+ pass
42
+
43
+
44
+ class AccessDeniedError(WBApiError):
45
+ """403: Access denied. Deleted user, blocked access, or missing Jam subscription."""
46
+
47
+ pass
48
+
49
+
50
+ class NotFoundError(WBApiError):
51
+ """404: Not found. Check the request URL."""
52
+
53
+ pass
54
+
55
+
56
+ class ConflictError(WBApiError):
57
+ """409: Status update error / Error adding label. Data contradicts limits."""
58
+
59
+ pass
60
+
61
+
62
+ class PayloadTooLargeError(WBApiError):
63
+ """413: The request body size exceeds the given limit."""
64
+
65
+ pass
66
+
67
+
68
+ class UnprocessableEntityError(WBApiError):
69
+ """422: Error processing request parameters or unexpected result."""
70
+
71
+ pass
72
+
73
+
74
+ class TooManyRequestsError(WBApiError):
75
+ """429: Too many requests. Rate limit exceeded."""
76
+
77
+ pass
78
+
79
+
80
+ class InternalServerError(WBApiError):
81
+ """5XX: Internal service error. Service is unavailable."""
82
+
83
+ pass
@@ -0,0 +1,12 @@
1
+ from .base import WBMethod, WT
2
+ from .ping import Ping
3
+ from .statistics import GetOrders
4
+ from .update_product_card import UpdateProductCard
5
+
6
+ __all__ = [
7
+ "Ping",
8
+ "UpdateProductCard",
9
+ "WBMethod",
10
+ "WT",
11
+ "GetOrders",
12
+ ]
pywb/methods/base.py ADDED
@@ -0,0 +1,16 @@
1
+ from pydantic import BaseModel
2
+ from typing import TypeVar, Generic, ClassVar
3
+ from ..enums import WBDomain
4
+
5
+ WT = TypeVar("WT")
6
+
7
+
8
+ class WBMethod(BaseModel, Generic[WT]):
9
+ """Базовый класс для всех методов API"""
10
+
11
+ __http_method__: ClassVar[str]
12
+ __api_path__: ClassVar[str]
13
+ __domain__: ClassVar[WBDomain]
14
+ __returning__: ClassVar[type]
15
+
16
+ model_config = {"extra": "forbid"}
pywb/methods/ping.py ADDED
@@ -0,0 +1,24 @@
1
+ from .base import WBMethod
2
+ from ..enums import WBDomain
3
+ from ..types import PingResponse
4
+
5
+
6
+ class Ping(WBMethod[PingResponse]):
7
+ __http_method__ = "GET"
8
+ __api_path__ = "/ping"
9
+ __domain__ = WBDomain.COMMON
10
+ __returning__ = PingResponse
11
+
12
+
13
+ class PingContent(WBMethod[PingResponse]):
14
+ __http_method__ = "GET"
15
+ __api_path__ = "/ping"
16
+ __domain__ = WBDomain.CONTENT
17
+ __returning__ = PingResponse
18
+
19
+
20
+ class PingAnalytics(WBMethod[PingResponse]):
21
+ __http_method__ = "GET"
22
+ __api_path__ = "/ping"
23
+ __domain__ = WBDomain.ANALYTICS
24
+ __returning__ = PingResponse
@@ -0,0 +1,19 @@
1
+ from typing import ClassVar
2
+
3
+ from pywb.types.order import StatisticOrder
4
+ from .base import WBMethod
5
+ from ..enums import WBDomain
6
+
7
+
8
+ class GetOrders(WBMethod[list[StatisticOrder]]):
9
+ """
10
+ Команда для получения отчета по заказам.
11
+ """
12
+
13
+ __http_method__: ClassVar[str] = "GET"
14
+ __api_path__: ClassVar[str] = "/api/v1/supplier/orders"
15
+ __domain__: ClassVar[WBDomain] = WBDomain.STATISTICS
16
+ __returning__: ClassVar[type] = list[StatisticOrder]
17
+
18
+ dateFrom: str
19
+ flag: int = 0
@@ -0,0 +1,16 @@
1
+ from typing import Optional
2
+ from .base import WBMethod
3
+
4
+
5
+ class UpdateProductCard(WBMethod[bool]):
6
+ """
7
+ Класс-команда для обновления карточки товара.
8
+ """
9
+
10
+ __http_method__ = "POST"
11
+ __api_path__ = "/content/v1/cards/update"
12
+ __returning__ = bool
13
+
14
+ card_id: str
15
+ price: int
16
+ discount: Optional[int] = None
pywb/types/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ from .ping_response import PingResponse
2
+ from .order import StatisticOrder
3
+
4
+ __all__ = [
5
+ "PingResponse",
6
+ "StatisticOrder",
7
+ ]
pywb/types/order.py ADDED
@@ -0,0 +1,34 @@
1
+ from datetime import datetime
2
+ from pydantic import BaseModel, Field
3
+
4
+
5
+ class StatisticOrder(BaseModel):
6
+ """Модель информации о заказе из API Статистики."""
7
+
8
+ date: datetime
9
+ last_change_date: datetime = Field(alias="lastChangeDate")
10
+ warehouse_name: str = Field(alias="warehouseName")
11
+ warehouse_type: str = Field(alias="warehouseType")
12
+ country_name: str = Field(alias="countryName")
13
+ oblast_okrug_name: str = Field(alias="oblastOkrugName")
14
+ region_name: str = Field(alias="regionName")
15
+ supplier_article: str = Field(alias="supplierArticle")
16
+ nm_id: int = Field(alias="nmId")
17
+ barcode: str
18
+ category: str
19
+ subject: str
20
+ brand: str
21
+ tech_size: str = Field(alias="techSize")
22
+ income_id: int = Field(alias="incomeID")
23
+ is_supply: bool = Field(alias="isSupply")
24
+ is_realization: bool = Field(alias="isRealization")
25
+ total_price: float = Field(alias="totalPrice")
26
+ discount_percent: int = Field(alias="discountPercent")
27
+ spp: float
28
+ finished_price: float = Field(alias="finishedPrice")
29
+ price_with_disc: float = Field(alias="priceWithDisc")
30
+ is_cancel: bool = Field(alias="isCancel")
31
+ cancel_date: datetime = Field(alias="cancelDate")
32
+ sticker: str
33
+ g_number: str = Field(alias="gNumber")
34
+ srid: str
@@ -0,0 +1,6 @@
1
+ from pydantic import BaseModel, Field
2
+
3
+
4
+ class PingResponse(BaseModel):
5
+ ts: str = Field(alias="TS")
6
+ status: str = Field(alias="Status")
pywb/utils/errors.py ADDED
@@ -0,0 +1,46 @@
1
+ from typing import Any, Dict, Optional
2
+
3
+ from ..exceptions import (
4
+ BadRequestError,
5
+ ConflictError,
6
+ InternalServerError,
7
+ NotFoundError,
8
+ PayloadTooLargeError,
9
+ PaymentRequiredError,
10
+ TooManyRequestsError,
11
+ UnauthorizedError,
12
+ UnprocessableEntityError,
13
+ AccessDeniedError,
14
+ WBApiError,
15
+ )
16
+
17
+
18
+ _EXCEPTION_MAPPING = {
19
+ 400: BadRequestError,
20
+ 401: UnauthorizedError,
21
+ 402: PaymentRequiredError,
22
+ 403: AccessDeniedError,
23
+ 404: NotFoundError,
24
+ 409: ConflictError,
25
+ 413: PayloadTooLargeError,
26
+ 422: UnprocessableEntityError,
27
+ 429: TooManyRequestsError,
28
+ }
29
+
30
+
31
+ def raise_for_status(
32
+ status_code: int,
33
+ response_json: Optional[Dict[str, Any]] = None,
34
+ ):
35
+ """
36
+ Helper function to raise the appropriate exception based on the status code.
37
+ If the status code is 200 or 204, it does nothing.
38
+ """
39
+ if status_code in (200, 204):
40
+ return
41
+
42
+ if 500 <= status_code < 600:
43
+ raise InternalServerError(status_code, response_json)
44
+
45
+ exception_class = _EXCEPTION_MAPPING.get(status_code, WBApiError)
46
+ raise exception_class(status_code, response_json)
test.py ADDED
@@ -0,0 +1,18 @@
1
+ import asyncio
2
+ from pywb import WBClient
3
+ from pywb.exceptions import BadRequestError
4
+
5
+
6
+ async def main():
7
+ token = "eyJhbGciOiJFUzI1NiIsImtpZCI6IjIwMjYwMzAydjEiLCJ0eXAiOiJKV1QifQ.eyJhY2MiOjEsImVudCI6MSwiZXhwIjoxNzkwMzk3MTQxLCJpZCI6IjAxOWQzMDIzLWJkMGMtNzYyNy1hZmQ4LTVmOGIwMWVmOGEwYyIsImlpZCI6MTI4NDg1NDE5LCJvaWQiOjI1MDEyNDEwOCwicyI6MTYxMjYsInNpZCI6ImJjMDRiNDUyLTlkMWMtNDM3MC1hZDUwLTc1NDQzYmJiZWQzMCIsInQiOmZhbHNlLCJ1aWQiOjEyODQ4NTQxOX0.r2N1FXLRM8PZM43PzkKa4HgrdFSYf5Nz3s2JXUPrcUOZ0RicfvWqa4JowK6FVDps8pHoqsnHDukuRLvNKqvCbQ"
8
+
9
+ async with WBClient(token, is_sandbox=False) as client:
10
+ try:
11
+ result = await client.get_orders(date_from="2022-03-04T18:08:31")
12
+ print(result[0].brand)
13
+ except BadRequestError as e:
14
+ print(f"Error: {e}", e.payload)
15
+
16
+
17
+ if __name__ == "__main__":
18
+ asyncio.run(main())