wbapi-async 0.7.7__tar.gz → 0.7.9__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.
- wbapi_async-0.7.9/PKG-INFO +130 -0
- wbapi_async-0.7.9/README.md +86 -0
- {wbapi_async-0.7.7 → wbapi_async-0.7.9}/pyproject.toml +1 -1
- {wbapi_async-0.7.7 → wbapi_async-0.7.9}/src/wbapi/__init__.py +3 -4
- wbapi_async-0.7.9/src/wbapi/_core/__init__.py +5 -0
- wbapi_async-0.7.9/src/wbapi/_core/_method.py +62 -0
- wbapi_async-0.7.9/src/wbapi/_core/_paginators.py +165 -0
- wbapi_async-0.7.7/src/wbapi/client/session/base.py → wbapi_async-0.7.9/src/wbapi/_core/_session.py +26 -2
- wbapi_async-0.7.9/src/wbapi/_core/limiter.py +16 -0
- wbapi_async-0.7.9/src/wbapi/_core/paginate.py +27 -0
- wbapi_async-0.7.9/src/wbapi/_core/parse_url.py +31 -0
- wbapi_async-0.7.7/src/wbapi/api.py → wbapi_async-0.7.9/src/wbapi/_main.py +36 -37
- {wbapi_async-0.7.7 → wbapi_async-0.7.9}/src/wbapi/exceptions.py +0 -4
- wbapi_async-0.7.7/PKG-INFO +0 -94
- wbapi_async-0.7.7/README.md +0 -50
- wbapi_async-0.7.7/src/wbapi/_method.py +0 -180
- wbapi_async-0.7.7/src/wbapi/client/__init__.py +0 -0
- wbapi_async-0.7.7/src/wbapi/client/session/__init__.py +0 -4
- wbapi_async-0.7.7/src/wbapi/client/session/headers.py +0 -23
- wbapi_async-0.7.7/src/wbapi/utils/__init__.py +0 -0
- wbapi_async-0.7.7/src/wbapi/utils/paginate.py +0 -155
- {wbapi_async-0.7.7 → wbapi_async-0.7.9}/.gitignore +0 -0
- {wbapi_async-0.7.7 → wbapi_async-0.7.9}/LICENSE +0 -0
- {wbapi_async-0.7.7/src/wbapi → wbapi_async-0.7.9/src/wbapi/_core}/_registry.py +0 -0
- {wbapi_async-0.7.7/src/wbapi → wbapi_async-0.7.9/src/wbapi/_core}/type.py +0 -0
- {wbapi_async-0.7.7 → wbapi_async-0.7.9}/src/wbapi/py.typed +0 -0
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: wbapi-async
|
|
3
|
+
Version: 0.7.9
|
|
4
|
+
Summary: Fast, lightweight async client for the Wildberries Seller API
|
|
5
|
+
Project-URL: Documentation, https://serdukow.github.io/wbapi-async/
|
|
6
|
+
Project-URL: Repository, https://github.com/serdukow/wbapi-async
|
|
7
|
+
Project-URL: Changelog, https://github.com/serdukow/wbapi-async/blob/main/CHANGELOG.md
|
|
8
|
+
Project-URL: Issues, https://github.com/serdukow/wbapi-async/issues
|
|
9
|
+
Author-email: Andrei Serdiukov <asyncdf@gmail.com>
|
|
10
|
+
Maintainer-email: Andrei Serdiukov <asyncdf@gmail.com>
|
|
11
|
+
License: MIT
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Keywords: api,async,client,wb,wildberries
|
|
14
|
+
Classifier: Development Status :: 4 - Beta
|
|
15
|
+
Classifier: Framework :: AsyncIO
|
|
16
|
+
Classifier: Framework :: Pydantic :: 2
|
|
17
|
+
Classifier: Intended Audience :: Developers
|
|
18
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
19
|
+
Classifier: Programming Language :: Python :: 3
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
24
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
25
|
+
Classifier: Typing :: Typed
|
|
26
|
+
Requires-Python: <3.14,>=3.11
|
|
27
|
+
Requires-Dist: aiolimiter<2.0.0,>=1.2.1
|
|
28
|
+
Requires-Dist: httpx<0.29.0,>=0.28.1
|
|
29
|
+
Provides-Extra: dev
|
|
30
|
+
Requires-Dist: pre-commit<5.0.0,>=4.3.0; extra == 'dev'
|
|
31
|
+
Requires-Dist: ruff<0.15.0,>=0.14.1; extra == 'dev'
|
|
32
|
+
Provides-Extra: docs
|
|
33
|
+
Requires-Dist: mkdocs-material>=9.6.0; extra == 'docs'
|
|
34
|
+
Requires-Dist: mkdocstrings[python]>=0.29.0; extra == 'docs'
|
|
35
|
+
Provides-Extra: lint
|
|
36
|
+
Requires-Dist: pre-commit<5.0.0,>=4.3.0; extra == 'lint'
|
|
37
|
+
Requires-Dist: ruff<0.15.0,>=0.14.1; extra == 'lint'
|
|
38
|
+
Provides-Extra: test
|
|
39
|
+
Requires-Dist: pytest-asyncio<2.0.0,>=1.2.0; extra == 'test'
|
|
40
|
+
Requires-Dist: pytest-httpx<0.37.0,>=0.35.0; extra == 'test'
|
|
41
|
+
Requires-Dist: pytest<9.0.0,>=8.4.2; extra == 'test'
|
|
42
|
+
Requires-Dist: python-dotenv<2.0.0,>=1.2.1; extra == 'test'
|
|
43
|
+
Description-Content-Type: text/markdown
|
|
44
|
+
|
|
45
|
+
<p align="center">
|
|
46
|
+
<a href="https://dev.wildberries.ru"><img src="https://i.ibb.co/dwsMVbNC/logo.png" alt="wbapi"></a>
|
|
47
|
+
</p>
|
|
48
|
+
|
|
49
|
+
<p align="center">
|
|
50
|
+
<a href="https://img.shields.io/pypi/v/wbapi-async.svg">
|
|
51
|
+
<img src="https://img.shields.io/pypi/v/wbapi-async.svg" alt="Version">
|
|
52
|
+
</a>
|
|
53
|
+
<a href="https://pypi.python.org/pypi/wbapi-async">
|
|
54
|
+
<img src="https://img.shields.io/pypi/dm/wbapi-async.svg" alt="Downloads">
|
|
55
|
+
</a>
|
|
56
|
+
<a href="https://pypi.python.org/pypi/wbapi-async">
|
|
57
|
+
<img src="https://img.shields.io/badge/status-stable-green.svg?logo=git&logoColor=green" alt="Status">
|
|
58
|
+
</a>
|
|
59
|
+
<a href="https://pypi.org/project/wbapi-async">
|
|
60
|
+
<img src="https://img.shields.io/pypi/pyversions/wbapi-async.svg" alt="Python">
|
|
61
|
+
</a>
|
|
62
|
+
</p>
|
|
63
|
+
|
|
64
|
+
---
|
|
65
|
+
|
|
66
|
+
**Documentation**: [https://dev.wildberries.ru](https://dev.wildberries.ru)
|
|
67
|
+
|
|
68
|
+
**Source Code**: [https://github.com/serdukow/wbapi-async](https://github.com/serdukow/wbapi-async)
|
|
69
|
+
|
|
70
|
+
---
|
|
71
|
+
|
|
72
|
+
**wbapi** is a lightweight async client for the Wildberries Seller API, built on top of [httpx](https://www.python-httpx.org/).
|
|
73
|
+
|
|
74
|
+
It handles pagination, rate limiting, and base URL resolution automatically — so you can focus on your business logic instead of HTTP boilerplate.
|
|
75
|
+
|
|
76
|
+
## Requirements
|
|
77
|
+
|
|
78
|
+
Python 3.10+
|
|
79
|
+
|
|
80
|
+
wbapi stands on the shoulders of giants:
|
|
81
|
+
|
|
82
|
+
- [httpx](https://www.python-httpx.org/) for async HTTP.
|
|
83
|
+
- [aiolimiter](https://github.com/mjpieters/aiolimiter) for rate limiting.
|
|
84
|
+
|
|
85
|
+
## Installation
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
pip install wbapi-async
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## How to use
|
|
92
|
+
|
|
93
|
+
1. Register in the Wildberries seller [personal account](https://seller.wildberries.ru/) if you haven't already.
|
|
94
|
+
2. Go to store settings and [create an API token](https://dev.wildberries.ru/en/docs/openapi/api-information#tag/Authorization/How-to-create-a-personal-access-base-or-test-token).
|
|
95
|
+
|
|
96
|
+
## Quick start
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
import asyncio
|
|
100
|
+
from wbapi import WbAPI
|
|
101
|
+
|
|
102
|
+
async def main():
|
|
103
|
+
async with WbAPI(token="your_api_token") as api:
|
|
104
|
+
# Simple request
|
|
105
|
+
warehouses = await api.get("/api/v3/warehouses")
|
|
106
|
+
|
|
107
|
+
# Auto-paginated — fetches all pages automatically
|
|
108
|
+
all_cards = await api.post(
|
|
109
|
+
"/content/v2/get/cards/list",
|
|
110
|
+
body={
|
|
111
|
+
"settings": {
|
|
112
|
+
"sort": {"ascending": True},
|
|
113
|
+
"cursor": {"limit": 100},
|
|
114
|
+
"filter": {"withPhoto": -1},
|
|
115
|
+
}
|
|
116
|
+
},
|
|
117
|
+
paginate=True,
|
|
118
|
+
)
|
|
119
|
+
print(all_cards)
|
|
120
|
+
|
|
121
|
+
asyncio.run(main())
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Pass `paginate=True` to any `get` or `post` call and the library fetches all pages automatically. Pagination strategy (cursor, token, or offset) is detected from the first response.
|
|
125
|
+
|
|
126
|
+
Base URL resolution and per-endpoint rate limiting are handled transparently.
|
|
127
|
+
|
|
128
|
+
## License
|
|
129
|
+
|
|
130
|
+
This project is licensed under the terms of the [MIT license](https://github.com/serdukow/wbapi-async/blob/main/LICENSE).
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<a href="https://dev.wildberries.ru"><img src="https://i.ibb.co/dwsMVbNC/logo.png" alt="wbapi"></a>
|
|
3
|
+
</p>
|
|
4
|
+
|
|
5
|
+
<p align="center">
|
|
6
|
+
<a href="https://img.shields.io/pypi/v/wbapi-async.svg">
|
|
7
|
+
<img src="https://img.shields.io/pypi/v/wbapi-async.svg" alt="Version">
|
|
8
|
+
</a>
|
|
9
|
+
<a href="https://pypi.python.org/pypi/wbapi-async">
|
|
10
|
+
<img src="https://img.shields.io/pypi/dm/wbapi-async.svg" alt="Downloads">
|
|
11
|
+
</a>
|
|
12
|
+
<a href="https://pypi.python.org/pypi/wbapi-async">
|
|
13
|
+
<img src="https://img.shields.io/badge/status-stable-green.svg?logo=git&logoColor=green" alt="Status">
|
|
14
|
+
</a>
|
|
15
|
+
<a href="https://pypi.org/project/wbapi-async">
|
|
16
|
+
<img src="https://img.shields.io/pypi/pyversions/wbapi-async.svg" alt="Python">
|
|
17
|
+
</a>
|
|
18
|
+
</p>
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
**Documentation**: [https://dev.wildberries.ru](https://dev.wildberries.ru)
|
|
23
|
+
|
|
24
|
+
**Source Code**: [https://github.com/serdukow/wbapi-async](https://github.com/serdukow/wbapi-async)
|
|
25
|
+
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
**wbapi** is a lightweight async client for the Wildberries Seller API, built on top of [httpx](https://www.python-httpx.org/).
|
|
29
|
+
|
|
30
|
+
It handles pagination, rate limiting, and base URL resolution automatically — so you can focus on your business logic instead of HTTP boilerplate.
|
|
31
|
+
|
|
32
|
+
## Requirements
|
|
33
|
+
|
|
34
|
+
Python 3.10+
|
|
35
|
+
|
|
36
|
+
wbapi stands on the shoulders of giants:
|
|
37
|
+
|
|
38
|
+
- [httpx](https://www.python-httpx.org/) for async HTTP.
|
|
39
|
+
- [aiolimiter](https://github.com/mjpieters/aiolimiter) for rate limiting.
|
|
40
|
+
|
|
41
|
+
## Installation
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
pip install wbapi-async
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## How to use
|
|
48
|
+
|
|
49
|
+
1. Register in the Wildberries seller [personal account](https://seller.wildberries.ru/) if you haven't already.
|
|
50
|
+
2. Go to store settings and [create an API token](https://dev.wildberries.ru/en/docs/openapi/api-information#tag/Authorization/How-to-create-a-personal-access-base-or-test-token).
|
|
51
|
+
|
|
52
|
+
## Quick start
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
import asyncio
|
|
56
|
+
from wbapi import WbAPI
|
|
57
|
+
|
|
58
|
+
async def main():
|
|
59
|
+
async with WbAPI(token="your_api_token") as api:
|
|
60
|
+
# Simple request
|
|
61
|
+
warehouses = await api.get("/api/v3/warehouses")
|
|
62
|
+
|
|
63
|
+
# Auto-paginated — fetches all pages automatically
|
|
64
|
+
all_cards = await api.post(
|
|
65
|
+
"/content/v2/get/cards/list",
|
|
66
|
+
body={
|
|
67
|
+
"settings": {
|
|
68
|
+
"sort": {"ascending": True},
|
|
69
|
+
"cursor": {"limit": 100},
|
|
70
|
+
"filter": {"withPhoto": -1},
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
paginate=True,
|
|
74
|
+
)
|
|
75
|
+
print(all_cards)
|
|
76
|
+
|
|
77
|
+
asyncio.run(main())
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Pass `paginate=True` to any `get` or `post` call and the library fetches all pages automatically. Pagination strategy (cursor, token, or offset) is detected from the first response.
|
|
81
|
+
|
|
82
|
+
Base URL resolution and per-endpoint rate limiting are handled transparently.
|
|
83
|
+
|
|
84
|
+
## License
|
|
85
|
+
|
|
86
|
+
This project is licensed under the terms of the [MIT license](https://github.com/serdukow/wbapi-async/blob/main/LICENSE).
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
from importlib.metadata import version
|
|
2
2
|
|
|
3
|
-
from .
|
|
4
|
-
from .
|
|
5
|
-
from .
|
|
3
|
+
from ._core.type import ApiResponse
|
|
4
|
+
from ._main import WbAPI
|
|
5
|
+
from .exceptions import TokenValidationError, WBAPIError
|
|
6
6
|
|
|
7
7
|
|
|
8
8
|
__version__ = version("wbapi-async")
|
|
@@ -11,7 +11,6 @@ __all__ = (
|
|
|
11
11
|
"WbAPI",
|
|
12
12
|
"WBAPIError",
|
|
13
13
|
"TokenValidationError",
|
|
14
|
-
"PaginationNotSupported",
|
|
15
14
|
"ApiResponse",
|
|
16
15
|
"__version__",
|
|
17
16
|
)
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from ._registry import _PAGE_SIZES, _PUBLIC
|
|
6
|
+
from ._session import BaseSession
|
|
7
|
+
from .limiter import _get_limiter
|
|
8
|
+
from .paginate import fetch_all
|
|
9
|
+
from .parse_url import parse_url
|
|
10
|
+
from .type import ApiResponse, _wrap
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class MethodDispatcher:
|
|
14
|
+
def __init__(self, session: BaseSession, token: str) -> None:
|
|
15
|
+
self._session = session
|
|
16
|
+
self._token = token
|
|
17
|
+
|
|
18
|
+
async def dispatch(
|
|
19
|
+
self,
|
|
20
|
+
http_method: str,
|
|
21
|
+
path: str,
|
|
22
|
+
*,
|
|
23
|
+
params: dict[str, Any] | None = None,
|
|
24
|
+
json: Any | None = None,
|
|
25
|
+
paginate: bool = False,
|
|
26
|
+
no_auth: bool = False,
|
|
27
|
+
) -> Any:
|
|
28
|
+
import re
|
|
29
|
+
from urllib.parse import urlparse
|
|
30
|
+
|
|
31
|
+
placeholders = re.findall(r"\{(\w+)\}", path)
|
|
32
|
+
if placeholders and params:
|
|
33
|
+
params = dict(params)
|
|
34
|
+
for key in placeholders:
|
|
35
|
+
if key in params:
|
|
36
|
+
path = path.replace(f"{{{key}}}", str(params.pop(key)))
|
|
37
|
+
params = params or None
|
|
38
|
+
|
|
39
|
+
if not no_auth and path.startswith(("https://", "http://")):
|
|
40
|
+
no_auth = urlparse(path).netloc in _PUBLIC
|
|
41
|
+
|
|
42
|
+
if no_auth:
|
|
43
|
+
self._session.headers.authorization = None
|
|
44
|
+
else:
|
|
45
|
+
self._session.headers.set_token(self._token)
|
|
46
|
+
|
|
47
|
+
if paginate:
|
|
48
|
+
page_size = _PAGE_SIZES.get(path, 1000)
|
|
49
|
+
|
|
50
|
+
async def _request(
|
|
51
|
+
params: dict[str, Any] | None = None,
|
|
52
|
+
body: dict[str, Any] | None = None,
|
|
53
|
+
) -> Any:
|
|
54
|
+
url = parse_url(path)
|
|
55
|
+
limiter = _get_limiter(path)
|
|
56
|
+
return await self._session._request(http_method, url, params=params, json=body, limit=limiter)
|
|
57
|
+
|
|
58
|
+
return [ApiResponse(item) for item in await fetch_all(_request, path, page_size, params, json)]
|
|
59
|
+
|
|
60
|
+
url = parse_url(path)
|
|
61
|
+
limiter = _get_limiter(path)
|
|
62
|
+
return _wrap(await self._session._request(http_method, url, params=params, json=json, limit=limiter))
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
_Requester = Callable[..., Any]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _extract_list(raw: Any) -> list[Any] | None:
|
|
11
|
+
if isinstance(raw, list):
|
|
12
|
+
return raw
|
|
13
|
+
if isinstance(raw, dict):
|
|
14
|
+
for v in raw.values():
|
|
15
|
+
if isinstance(v, list):
|
|
16
|
+
return v
|
|
17
|
+
for v in raw.values():
|
|
18
|
+
if isinstance(v, dict):
|
|
19
|
+
found = _extract_list(v)
|
|
20
|
+
if found is not None:
|
|
21
|
+
return found
|
|
22
|
+
return None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
async def next_token(
|
|
26
|
+
raw: Any,
|
|
27
|
+
page: list[Any],
|
|
28
|
+
request: _Requester,
|
|
29
|
+
params: dict[str, Any],
|
|
30
|
+
page_size: int,
|
|
31
|
+
) -> list[Any]:
|
|
32
|
+
"""
|
|
33
|
+
Token continuation pagination: API returns ``next`` in the response root,
|
|
34
|
+
which is passed as ``?next=<value>`` in subsequent requests.
|
|
35
|
+
|
|
36
|
+
Used by: Marketplace (FBS/DBS/DBW orders), Supplies FBW, Q&A, Reviews.
|
|
37
|
+
Stop condition: ``next`` is falsy or the page is empty.
|
|
38
|
+
"""
|
|
39
|
+
result: list[Any] = list(page)
|
|
40
|
+
while True:
|
|
41
|
+
cursor = raw.get("next") if isinstance(raw, dict) else None
|
|
42
|
+
if not cursor or not page:
|
|
43
|
+
break
|
|
44
|
+
raw = await request(params={**params, "limit": page_size, "next": cursor})
|
|
45
|
+
page = _extract_list(raw) or []
|
|
46
|
+
result.extend(page)
|
|
47
|
+
return result
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
async def rrdid_token(
|
|
51
|
+
raw: Any,
|
|
52
|
+
page: list[Any],
|
|
53
|
+
request: _Requester,
|
|
54
|
+
params: dict[str, Any],
|
|
55
|
+
page_size: int,
|
|
56
|
+
) -> list[Any]:
|
|
57
|
+
"""
|
|
58
|
+
Finance report token pagination: last item in the page has ``rrd_id``,
|
|
59
|
+
which is passed as ``?rrdid=<value>`` in subsequent requests.
|
|
60
|
+
|
|
61
|
+
Used by: Finance (reportDetailByPeriod).
|
|
62
|
+
Stop condition: page is empty (API returns 204 No Content → empty list).
|
|
63
|
+
"""
|
|
64
|
+
result: list[Any] = list(page)
|
|
65
|
+
while page:
|
|
66
|
+
rrd_id = page[-1].get("rrd_id") if isinstance(page[-1], dict) else None
|
|
67
|
+
if not rrd_id:
|
|
68
|
+
break
|
|
69
|
+
raw = await request(params={**params, "rrdid": rrd_id})
|
|
70
|
+
page = _extract_list(raw) or []
|
|
71
|
+
result.extend(page)
|
|
72
|
+
return result
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
async def offset(
|
|
76
|
+
raw: Any,
|
|
77
|
+
page: list[Any],
|
|
78
|
+
request: _Requester,
|
|
79
|
+
params: dict[str, Any],
|
|
80
|
+
page_size: int,
|
|
81
|
+
) -> list[Any]:
|
|
82
|
+
"""
|
|
83
|
+
Offset pagination: increments ``offset`` by ``limit`` each request.
|
|
84
|
+
|
|
85
|
+
Used by: Analytics, Promotions, Reports.
|
|
86
|
+
Stop condition: returned page is smaller than ``limit``.
|
|
87
|
+
"""
|
|
88
|
+
result: list[Any] = list(page)
|
|
89
|
+
current_offset = page_size
|
|
90
|
+
while len(page) >= page_size:
|
|
91
|
+
raw = await request(params={**params, "limit": page_size, "offset": current_offset})
|
|
92
|
+
page = _extract_list(raw) or []
|
|
93
|
+
result.extend(page)
|
|
94
|
+
current_offset += page_size
|
|
95
|
+
return result
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
async def cursor(
|
|
99
|
+
raw: Any,
|
|
100
|
+
page: list[Any],
|
|
101
|
+
request: _Requester,
|
|
102
|
+
body: dict[str, Any],
|
|
103
|
+
page_size: int,
|
|
104
|
+
) -> list[Any]:
|
|
105
|
+
"""
|
|
106
|
+
Cursor pagination (POST body): API returns ``cursor`` object with
|
|
107
|
+
``updatedAt``, ``nmID``, and ``total``. Next request merges cursor
|
|
108
|
+
fields into ``settings.cursor`` in the request body.
|
|
109
|
+
|
|
110
|
+
Used by: Content v2 (cards list).
|
|
111
|
+
Stop condition: ``cursor.total`` < ``limit``.
|
|
112
|
+
"""
|
|
113
|
+
result: list[Any] = list(page)
|
|
114
|
+
while True:
|
|
115
|
+
cursor_obj = raw.get("cursor", {}) if isinstance(raw, dict) else {}
|
|
116
|
+
total = cursor_obj.get("total", 0) if isinstance(cursor_obj, dict) else 0
|
|
117
|
+
if total < page_size:
|
|
118
|
+
break
|
|
119
|
+
next_body = {
|
|
120
|
+
**body,
|
|
121
|
+
"settings": {
|
|
122
|
+
**body.get("settings", {}),
|
|
123
|
+
"cursor": {
|
|
124
|
+
"limit": page_size,
|
|
125
|
+
"updatedAt": cursor_obj["updatedAt"],
|
|
126
|
+
"nmID": cursor_obj["nmID"],
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
}
|
|
130
|
+
raw = await request(body=next_body)
|
|
131
|
+
page = _extract_list(raw) or []
|
|
132
|
+
result.extend(page)
|
|
133
|
+
return result
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def detect_and_paginate(
|
|
137
|
+
raw: Any,
|
|
138
|
+
page: list[Any],
|
|
139
|
+
request: _Requester,
|
|
140
|
+
params: dict[str, Any],
|
|
141
|
+
body: dict[str, Any] | None,
|
|
142
|
+
page_size: int,
|
|
143
|
+
) -> Any:
|
|
144
|
+
"""
|
|
145
|
+
Auto-detect pagination type from first-page response and dispatch
|
|
146
|
+
to the appropriate paginator coroutine.
|
|
147
|
+
|
|
148
|
+
Detection order (first match wins):
|
|
149
|
+
1. CursorPaginator — POST body with ``cursor.updatedAt`` in response
|
|
150
|
+
2. NextTokenPaginator — ``next`` key in response root
|
|
151
|
+
3. RrdIdTokenPaginator — last item in page has ``rrd_id``
|
|
152
|
+
4. OffsetPaginator — page length == page_size (fallback)
|
|
153
|
+
"""
|
|
154
|
+
if body is not None and isinstance(raw, dict):
|
|
155
|
+
cursor_obj = raw.get("cursor", {})
|
|
156
|
+
if isinstance(cursor_obj, dict) and "updatedAt" in cursor_obj:
|
|
157
|
+
return cursor(raw, page, request, body, page_size)
|
|
158
|
+
|
|
159
|
+
if isinstance(raw, dict) and "next" in raw:
|
|
160
|
+
return next_token(raw, page, request, params, page_size)
|
|
161
|
+
|
|
162
|
+
if page and isinstance(page[-1], dict) and "rrd_id" in page[-1]:
|
|
163
|
+
return rrdid_token(raw, page, request, params, page_size)
|
|
164
|
+
|
|
165
|
+
return offset(raw, page, request, params, page_size)
|
wbapi_async-0.7.7/src/wbapi/client/session/base.py → wbapi_async-0.7.9/src/wbapi/_core/_session.py
RENAMED
|
@@ -7,8 +7,7 @@ from aiolimiter import AsyncLimiter
|
|
|
7
7
|
import httpx
|
|
8
8
|
from httpx import RequestError
|
|
9
9
|
|
|
10
|
-
from
|
|
11
|
-
from .headers import Headers
|
|
10
|
+
from ..exceptions import WBAPIError
|
|
12
11
|
|
|
13
12
|
|
|
14
13
|
log = logging.getLogger("wbapi.session")
|
|
@@ -16,6 +15,31 @@ logging.getLogger("httpx").setLevel(logging.WARNING)
|
|
|
16
15
|
logging.getLogger("httpcore").setLevel(logging.WARNING)
|
|
17
16
|
|
|
18
17
|
|
|
18
|
+
class Headers:
|
|
19
|
+
def __init__(self) -> None:
|
|
20
|
+
self.accept = "application/json;charset=utf-8"
|
|
21
|
+
self.content_type = "application/json"
|
|
22
|
+
self.authorization: str | None = None
|
|
23
|
+
self.user_agent = (
|
|
24
|
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
|
25
|
+
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
|
26
|
+
"Chrome/124.0.0.0 Safari/537.36"
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
def set_token(self, token: str) -> None:
|
|
30
|
+
self.authorization = f"Bearer {token}"
|
|
31
|
+
|
|
32
|
+
def as_dict(self) -> dict[str, str]:
|
|
33
|
+
headers: dict[str, str] = {
|
|
34
|
+
"Accept": self.accept,
|
|
35
|
+
"Content-Type": self.content_type,
|
|
36
|
+
"User-Agent": self.user_agent,
|
|
37
|
+
}
|
|
38
|
+
if self.authorization is not None:
|
|
39
|
+
headers["Authorization"] = self.authorization
|
|
40
|
+
return headers
|
|
41
|
+
|
|
42
|
+
|
|
19
43
|
class BaseSession:
|
|
20
44
|
base_url: str
|
|
21
45
|
timeout: int
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from aiolimiter import AsyncLimiter
|
|
2
|
+
|
|
3
|
+
from ._registry import _LIMITS
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
_DEFAULT_LIMIT: tuple[int, int, int, int] = (60000, 60, 1000, 5)
|
|
7
|
+
|
|
8
|
+
_limiters: dict[tuple[int, int], AsyncLimiter] = {}
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _get_limiter(path: str) -> AsyncLimiter:
|
|
12
|
+
period, limit, interval, burst = _LIMITS.get(path, _DEFAULT_LIMIT)
|
|
13
|
+
key = (interval, burst)
|
|
14
|
+
if key not in _limiters:
|
|
15
|
+
_limiters[key] = AsyncLimiter(max_rate=burst, time_period=interval / 1000)
|
|
16
|
+
return _limiters[key]
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from ._paginators import _extract_list, detect_and_paginate
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
_Requester = Callable[..., Any]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
async def fetch_all(
|
|
13
|
+
request: _Requester,
|
|
14
|
+
path: str,
|
|
15
|
+
page_size: int,
|
|
16
|
+
params: dict[str, Any] | None,
|
|
17
|
+
body: dict[str, Any] | None,
|
|
18
|
+
) -> list[Any]:
|
|
19
|
+
init_params = {**(params or {}), "limit": page_size}
|
|
20
|
+
if body is not None:
|
|
21
|
+
init_body = {**body}
|
|
22
|
+
raw = await request(params=init_params or None, body=init_body)
|
|
23
|
+
else:
|
|
24
|
+
raw = await request(params=init_params)
|
|
25
|
+
|
|
26
|
+
page = _extract_list(raw) or []
|
|
27
|
+
return await detect_and_paginate(raw, page, request, init_params, body, page_size)
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from ._registry import _BASES, _PUBLIC
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def parse_url(path: str) -> str:
|
|
5
|
+
"""Resolve full URL for a spec path like /api/v3/supplies.
|
|
6
|
+
|
|
7
|
+
If a full URL is passed, it is returned as-is provided its host is a known
|
|
8
|
+
wildberries.ru domain or is listed in ``_PUBLIC``.
|
|
9
|
+
"""
|
|
10
|
+
from urllib.parse import urlparse
|
|
11
|
+
|
|
12
|
+
from ..exceptions import WBAPIError
|
|
13
|
+
|
|
14
|
+
if path.startswith("https://") or path.startswith("http://"):
|
|
15
|
+
host = urlparse(path).netloc
|
|
16
|
+
known_hosts = {urlparse(v).netloc for v in _BASES.values()} | _PUBLIC
|
|
17
|
+
if host not in known_hosts:
|
|
18
|
+
raise WBAPIError(detail=f"Unknown host {host!r}.")
|
|
19
|
+
return path
|
|
20
|
+
|
|
21
|
+
base = _BASES.get(path)
|
|
22
|
+
if base:
|
|
23
|
+
return base + path
|
|
24
|
+
parts = path.rstrip("/").split("/")
|
|
25
|
+
for i in range(len(parts) - 1, 0, -1):
|
|
26
|
+
candidate = "/".join(parts[:i])
|
|
27
|
+
if candidate in _BASES:
|
|
28
|
+
return _BASES[candidate] + path
|
|
29
|
+
raise WBAPIError(
|
|
30
|
+
detail=f"{path!r} may be deprecated or removed. See https://dev.wildberries.ru/release-notes"
|
|
31
|
+
)
|
|
@@ -2,9 +2,7 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
from typing import Any
|
|
4
4
|
|
|
5
|
-
from .
|
|
6
|
-
from .client.session.base import BaseSession
|
|
7
|
-
from .type import ApiResponse, _wrap
|
|
5
|
+
from ._core import BaseSession, MethodDispatcher
|
|
8
6
|
|
|
9
7
|
|
|
10
8
|
class WbAPI:
|
|
@@ -25,59 +23,60 @@ class WbAPI:
|
|
|
25
23
|
self.session = session or BaseSession(base="https://wildberries.ru", timeout=timeout)
|
|
26
24
|
self._dispatcher = MethodDispatcher(self.session, token)
|
|
27
25
|
|
|
28
|
-
async def get(self, path: str, **kwargs: Any) -> Any:
|
|
26
|
+
async def get(self, path: str, *, paginate: bool = False, **kwargs: Any) -> Any:
|
|
29
27
|
"""
|
|
30
28
|
Send a GET request. kwargs become query parameters.
|
|
31
29
|
|
|
30
|
+
Pass ``paginate=True`` to fetch all pages automatically.
|
|
31
|
+
Pagination type is detected from the first response:
|
|
32
|
+
- ``next`` in response → token continuation (Marketplace, Supplies, Q&A)
|
|
33
|
+
- ``rrd_id`` in last item → rrdid token (Finance reports)
|
|
34
|
+
- fallback → offset (Analytics, Promotions, Reports)
|
|
35
|
+
|
|
32
36
|
Example::
|
|
37
|
+
|
|
33
38
|
warehouses = await api.get("/api/v3/warehouses")
|
|
34
39
|
orders = await api.get(
|
|
35
40
|
"/api/v3/orders/new", limit=10, next=0
|
|
36
41
|
)
|
|
37
|
-
meta = await api.get(
|
|
38
|
-
"/api/v3/orders/{orderId}/meta", orderId=123
|
|
39
|
-
)
|
|
40
|
-
"""
|
|
41
|
-
return _wrap(await self._dispatcher.dispatch("GET", path, params=kwargs or None))
|
|
42
42
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
"
|
|
51
|
-
body={"settings": {"sort": {"ascending": False}}},
|
|
43
|
+
all_orders = await api.get(
|
|
44
|
+
"/api/v3/orders", paginate=True, dateFrom=1698045576
|
|
45
|
+
)
|
|
46
|
+
all_sales = await api.get(
|
|
47
|
+
"/api/finance/v1/sales-reports/detailed",
|
|
48
|
+
paginate=True,
|
|
49
|
+
dateFrom="2024-01-01",
|
|
50
|
+
dateTo="2024-01-31",
|
|
52
51
|
)
|
|
53
52
|
"""
|
|
54
|
-
return
|
|
53
|
+
return await self._dispatcher.dispatch("GET", path, params=kwargs or None, paginate=paginate)
|
|
55
54
|
|
|
56
|
-
async def post(self, path: str, *, body: Any = None, **kwargs: Any) -> Any:
|
|
55
|
+
async def post(self, path: str, *, body: Any = None, paginate: bool = False, **kwargs: Any) -> Any:
|
|
57
56
|
"""
|
|
58
57
|
Send a POST request. ``body`` is serialized as JSON. kwargs become query parameters.
|
|
59
58
|
|
|
59
|
+
Pass ``paginate=True`` to fetch all pages automatically.
|
|
60
|
+
Pagination type is detected from the first response:
|
|
61
|
+
- ``cursor.updatedAt`` in response → cursor pagination (Content, cards list)
|
|
62
|
+
- fallback → offset
|
|
63
|
+
|
|
60
64
|
Example::
|
|
61
65
|
|
|
62
66
|
await api.post(
|
|
63
67
|
"/content/v2/get/cards/list",
|
|
64
|
-
body={
|
|
65
|
-
"settings": {
|
|
66
|
-
"sort": {"ascending": False},
|
|
67
|
-
"filter": {"textSearch": "12345"},
|
|
68
|
-
}
|
|
69
|
-
},
|
|
70
|
-
)
|
|
71
|
-
await api.post(
|
|
72
|
-
"/content/v2/tag/nomenclature/link",
|
|
73
|
-
body={"nmID": 179891389, "tagsIDs": [123456]},
|
|
68
|
+
body={"settings": {"sort": {"ascending": False}}},
|
|
74
69
|
)
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
70
|
+
|
|
71
|
+
all_cards = await api.post(
|
|
72
|
+
"/content/v2/get/cards/list",
|
|
73
|
+
body={"settings": {"filter": {"withPhoto": -1}}},
|
|
74
|
+
paginate=True,
|
|
78
75
|
)
|
|
79
76
|
"""
|
|
80
|
-
return
|
|
77
|
+
return await self._dispatcher.dispatch(
|
|
78
|
+
"POST", path, json=body, params=kwargs or None, paginate=paginate
|
|
79
|
+
)
|
|
81
80
|
|
|
82
81
|
async def put(self, path: str, *, body: Any = None, **kwargs: Any) -> Any:
|
|
83
82
|
"""
|
|
@@ -90,7 +89,7 @@ class WbAPI:
|
|
|
90
89
|
body={"stocks": [{"sku": "WB007", "amount": 10}]},
|
|
91
90
|
)
|
|
92
91
|
"""
|
|
93
|
-
return
|
|
92
|
+
return await self._dispatcher.dispatch("PUT", path, json=body, params=kwargs or None)
|
|
94
93
|
|
|
95
94
|
async def patch(self, path: str, *, body: Any = None, **kwargs: Any) -> Any:
|
|
96
95
|
"""
|
|
@@ -101,7 +100,7 @@ class WbAPI:
|
|
|
101
100
|
await api.patch("/api/v3/orders/13833711/cancel")
|
|
102
101
|
await api.patch("/content/v2/tag/99", body={"name": "sale"})
|
|
103
102
|
"""
|
|
104
|
-
return
|
|
103
|
+
return await self._dispatcher.dispatch("PATCH", path, json=body, params=kwargs or None)
|
|
105
104
|
|
|
106
105
|
async def delete(self, path: str, **kwargs: Any) -> Any:
|
|
107
106
|
"""
|
|
@@ -112,7 +111,7 @@ class WbAPI:
|
|
|
112
111
|
await api.delete("/content/v2/tag/99")
|
|
113
112
|
await api.delete("/api/v1/user", deletedUserID="abc-123")
|
|
114
113
|
"""
|
|
115
|
-
return
|
|
114
|
+
return await self._dispatcher.dispatch("DELETE", path, params=kwargs or None)
|
|
116
115
|
|
|
117
116
|
async def __aenter__(self) -> WbAPI:
|
|
118
117
|
return self
|
wbapi_async-0.7.7/PKG-INFO
DELETED
|
@@ -1,94 +0,0 @@
|
|
|
1
|
-
Metadata-Version: 2.4
|
|
2
|
-
Name: wbapi-async
|
|
3
|
-
Version: 0.7.7
|
|
4
|
-
Summary: Fast, lightweight async client for the Wildberries Seller API
|
|
5
|
-
Project-URL: Documentation, https://serdukow.github.io/wbapi-async/
|
|
6
|
-
Project-URL: Repository, https://github.com/serdukow/wbapi-async
|
|
7
|
-
Project-URL: Changelog, https://github.com/serdukow/wbapi-async/blob/main/CHANGELOG.md
|
|
8
|
-
Project-URL: Issues, https://github.com/serdukow/wbapi-async/issues
|
|
9
|
-
Author-email: Andrei Serdiukov <asyncdf@gmail.com>
|
|
10
|
-
Maintainer-email: Andrei Serdiukov <asyncdf@gmail.com>
|
|
11
|
-
License: MIT
|
|
12
|
-
License-File: LICENSE
|
|
13
|
-
Keywords: api,async,client,wb,wildberries
|
|
14
|
-
Classifier: Development Status :: 4 - Beta
|
|
15
|
-
Classifier: Framework :: AsyncIO
|
|
16
|
-
Classifier: Framework :: Pydantic :: 2
|
|
17
|
-
Classifier: Intended Audience :: Developers
|
|
18
|
-
Classifier: License :: OSI Approved :: MIT License
|
|
19
|
-
Classifier: Programming Language :: Python :: 3
|
|
20
|
-
Classifier: Programming Language :: Python :: 3.10
|
|
21
|
-
Classifier: Programming Language :: Python :: 3.11
|
|
22
|
-
Classifier: Programming Language :: Python :: 3.12
|
|
23
|
-
Classifier: Programming Language :: Python :: 3.13
|
|
24
|
-
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
25
|
-
Classifier: Typing :: Typed
|
|
26
|
-
Requires-Python: <3.14,>=3.11
|
|
27
|
-
Requires-Dist: aiolimiter<2.0.0,>=1.2.1
|
|
28
|
-
Requires-Dist: httpx<0.29.0,>=0.28.1
|
|
29
|
-
Provides-Extra: dev
|
|
30
|
-
Requires-Dist: pre-commit<5.0.0,>=4.3.0; extra == 'dev'
|
|
31
|
-
Requires-Dist: ruff<0.15.0,>=0.14.1; extra == 'dev'
|
|
32
|
-
Provides-Extra: docs
|
|
33
|
-
Requires-Dist: mkdocs-material>=9.6.0; extra == 'docs'
|
|
34
|
-
Requires-Dist: mkdocstrings[python]>=0.29.0; extra == 'docs'
|
|
35
|
-
Provides-Extra: lint
|
|
36
|
-
Requires-Dist: pre-commit<5.0.0,>=4.3.0; extra == 'lint'
|
|
37
|
-
Requires-Dist: ruff<0.15.0,>=0.14.1; extra == 'lint'
|
|
38
|
-
Provides-Extra: test
|
|
39
|
-
Requires-Dist: pytest-asyncio<2.0.0,>=1.2.0; extra == 'test'
|
|
40
|
-
Requires-Dist: pytest-httpx<0.37.0,>=0.35.0; extra == 'test'
|
|
41
|
-
Requires-Dist: pytest<9.0.0,>=8.4.2; extra == 'test'
|
|
42
|
-
Requires-Dist: python-dotenv<2.0.0,>=1.2.1; extra == 'test'
|
|
43
|
-
Description-Content-Type: text/markdown
|
|
44
|
-
|
|
45
|
-

|
|
46
|
-
|
|
47
|
-
<div align="center">
|
|
48
|
-
|
|
49
|
-
#### Fast, lightweight async client for the Wildberries Seller API
|
|
50
|
-
|
|
51
|
-
[](https://opensource.org/licenses/MIT)
|
|
52
|
-
[](https://pypi.org/project/wild-api/)
|
|
53
|
-
[](https://pypi.python.org/pypi/wild-api)
|
|
54
|
-
[](https://serdukow.github.io/wbapi-async/)
|
|
55
|
-
[](https://pypi.org/project/wbapi-async/)
|
|
56
|
-
|
|
57
|
-
</div>
|
|
58
|
-
|
|
59
|
-
## Features
|
|
60
|
-
|
|
61
|
-
- **Zero boilerplate** — responses are plain attribute-accessible dicts
|
|
62
|
-
- **Auto-pagination** — `get_all()` fetches all pages. Auto-detects all known strategies; you can easily add your own via a custom paginator — [see docs](https://github.com/serdukow/wbapi-async/blob/dev/docs/advanced/pagination.md)
|
|
63
|
-
- **Rate limiting** — per-endpoint limits powered by `aiolimiter`
|
|
64
|
-
- **Auto-retry** — automatic retry
|
|
65
|
-
- **Always up to date** — path registry is updated every monday
|
|
66
|
-
- **Fully async** — built on `httpx` + `asyncio`
|
|
67
|
-
|
|
68
|
-
## Install
|
|
69
|
-
|
|
70
|
-
```bash
|
|
71
|
-
pip install wbapi-async
|
|
72
|
-
```
|
|
73
|
-
|
|
74
|
-
## Quick start
|
|
75
|
-
|
|
76
|
-
```python
|
|
77
|
-
import asyncio
|
|
78
|
-
from wbapi import WbAPI
|
|
79
|
-
|
|
80
|
-
async def main():
|
|
81
|
-
async with WbAPI(token="YOUR_TOKEN") as api:
|
|
82
|
-
body = {
|
|
83
|
-
"settings": {
|
|
84
|
-
"sort": {"ascending": True},
|
|
85
|
-
"cursor": {"limit": 100},
|
|
86
|
-
"filter": {"withPhoto": -1},
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
cards = await api.get_all("/content/v2/get/cards/list", body=body)
|
|
91
|
-
print(f"cards: {cards!r}")
|
|
92
|
-
|
|
93
|
-
asyncio.run(main())
|
|
94
|
-
```
|
wbapi_async-0.7.7/README.md
DELETED
|
@@ -1,50 +0,0 @@
|
|
|
1
|
-

|
|
2
|
-
|
|
3
|
-
<div align="center">
|
|
4
|
-
|
|
5
|
-
#### Fast, lightweight async client for the Wildberries Seller API
|
|
6
|
-
|
|
7
|
-
[](https://opensource.org/licenses/MIT)
|
|
8
|
-
[](https://pypi.org/project/wild-api/)
|
|
9
|
-
[](https://pypi.python.org/pypi/wild-api)
|
|
10
|
-
[](https://serdukow.github.io/wbapi-async/)
|
|
11
|
-
[](https://pypi.org/project/wbapi-async/)
|
|
12
|
-
|
|
13
|
-
</div>
|
|
14
|
-
|
|
15
|
-
## Features
|
|
16
|
-
|
|
17
|
-
- **Zero boilerplate** — responses are plain attribute-accessible dicts
|
|
18
|
-
- **Auto-pagination** — `get_all()` fetches all pages. Auto-detects all known strategies; you can easily add your own via a custom paginator — [see docs](https://github.com/serdukow/wbapi-async/blob/dev/docs/advanced/pagination.md)
|
|
19
|
-
- **Rate limiting** — per-endpoint limits powered by `aiolimiter`
|
|
20
|
-
- **Auto-retry** — automatic retry
|
|
21
|
-
- **Always up to date** — path registry is updated every monday
|
|
22
|
-
- **Fully async** — built on `httpx` + `asyncio`
|
|
23
|
-
|
|
24
|
-
## Install
|
|
25
|
-
|
|
26
|
-
```bash
|
|
27
|
-
pip install wbapi-async
|
|
28
|
-
```
|
|
29
|
-
|
|
30
|
-
## Quick start
|
|
31
|
-
|
|
32
|
-
```python
|
|
33
|
-
import asyncio
|
|
34
|
-
from wbapi import WbAPI
|
|
35
|
-
|
|
36
|
-
async def main():
|
|
37
|
-
async with WbAPI(token="YOUR_TOKEN") as api:
|
|
38
|
-
body = {
|
|
39
|
-
"settings": {
|
|
40
|
-
"sort": {"ascending": True},
|
|
41
|
-
"cursor": {"limit": 100},
|
|
42
|
-
"filter": {"withPhoto": -1},
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
cards = await api.get_all("/content/v2/get/cards/list", body=body)
|
|
47
|
-
print(f"cards: {cards!r}")
|
|
48
|
-
|
|
49
|
-
asyncio.run(main())
|
|
50
|
-
```
|
|
@@ -1,180 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
from typing import TYPE_CHECKING, Any
|
|
4
|
-
|
|
5
|
-
from aiolimiter import AsyncLimiter
|
|
6
|
-
|
|
7
|
-
from ._registry import _BASES, _LIMITS, _PAGE_SIZES, _PUBLIC
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
if TYPE_CHECKING:
|
|
11
|
-
from .client.session.base import BaseSession
|
|
12
|
-
|
|
13
|
-
_DEFAULT_LIMIT: tuple[int, int, int, int] = (60000, 60, 1000, 5)
|
|
14
|
-
|
|
15
|
-
_limiters: dict[tuple[int, int], AsyncLimiter] = {}
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
def _get_limiter(path: str) -> AsyncLimiter:
|
|
19
|
-
period, limit, interval, burst = _LIMITS.get(path, _DEFAULT_LIMIT)
|
|
20
|
-
key = (interval, burst)
|
|
21
|
-
if key not in _limiters:
|
|
22
|
-
_limiters[key] = AsyncLimiter(max_rate=burst, time_period=interval / 1000)
|
|
23
|
-
return _limiters[key]
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
def resolve_url(path: str) -> str:
|
|
27
|
-
"""Resolve full URL for a spec path like /api/v3/supplies.
|
|
28
|
-
|
|
29
|
-
If a full URL is passed, it is returned as-is provided its host is a known
|
|
30
|
-
wildberries.ru domain or is listed in ``_PUBLIC``.
|
|
31
|
-
"""
|
|
32
|
-
from urllib.parse import urlparse
|
|
33
|
-
|
|
34
|
-
from .exceptions import WBAPIError
|
|
35
|
-
|
|
36
|
-
if path.startswith("https://") or path.startswith("http://"):
|
|
37
|
-
host = urlparse(path).netloc
|
|
38
|
-
known_hosts = {urlparse(v).netloc for v in _BASES.values()} | _PUBLIC
|
|
39
|
-
if host not in known_hosts:
|
|
40
|
-
raise WBAPIError(detail=f"Unknown host {host!r}.")
|
|
41
|
-
return path
|
|
42
|
-
|
|
43
|
-
base = _BASES.get(path)
|
|
44
|
-
if base:
|
|
45
|
-
return base + path
|
|
46
|
-
# Path may contain dynamic segments (e.g. /api/v3/supplies/WB-123/orders).
|
|
47
|
-
# Walk up the path until we find a match.
|
|
48
|
-
parts = path.rstrip("/").split("/")
|
|
49
|
-
for i in range(len(parts) - 1, 0, -1):
|
|
50
|
-
candidate = "/".join(parts[:i])
|
|
51
|
-
if candidate in _BASES:
|
|
52
|
-
return _BASES[candidate] + path
|
|
53
|
-
raise WBAPIError(
|
|
54
|
-
detail=f"{path!r} may be deprecated or removed. See https://dev.wildberries.ru/release-notes"
|
|
55
|
-
)
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
def _extract_list(raw: Any) -> list[Any] | None:
|
|
59
|
-
if isinstance(raw, list):
|
|
60
|
-
return raw
|
|
61
|
-
if isinstance(raw, dict):
|
|
62
|
-
for v in raw.values():
|
|
63
|
-
if isinstance(v, list):
|
|
64
|
-
return v
|
|
65
|
-
for v in raw.values():
|
|
66
|
-
if isinstance(v, dict):
|
|
67
|
-
found = _extract_list(v)
|
|
68
|
-
if found is not None:
|
|
69
|
-
return found
|
|
70
|
-
return None
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
class MethodDispatcher:
|
|
74
|
-
def __init__(self, session: BaseSession, token: str) -> None:
|
|
75
|
-
self._session = session
|
|
76
|
-
self._token = token
|
|
77
|
-
|
|
78
|
-
async def dispatch(
|
|
79
|
-
self,
|
|
80
|
-
http_method: str,
|
|
81
|
-
path: str,
|
|
82
|
-
*,
|
|
83
|
-
params: dict[str, Any] | None = None,
|
|
84
|
-
json: Any | None = None,
|
|
85
|
-
no_auth: bool = False,
|
|
86
|
-
) -> Any:
|
|
87
|
-
import re
|
|
88
|
-
from urllib.parse import urlparse
|
|
89
|
-
|
|
90
|
-
placeholders = re.findall(r"\{(\w+)\}", path)
|
|
91
|
-
if placeholders and params:
|
|
92
|
-
params = dict(params)
|
|
93
|
-
for key in placeholders:
|
|
94
|
-
if key in params:
|
|
95
|
-
path = path.replace(f"{{{key}}}", str(params.pop(key)))
|
|
96
|
-
params = params or None
|
|
97
|
-
|
|
98
|
-
if not no_auth and path.startswith(("https://", "http://")):
|
|
99
|
-
no_auth = urlparse(path).netloc in _PUBLIC
|
|
100
|
-
|
|
101
|
-
if no_auth:
|
|
102
|
-
self._session.headers.authorization = None
|
|
103
|
-
else:
|
|
104
|
-
self._session.headers.set_token(self._token)
|
|
105
|
-
url = resolve_url(path)
|
|
106
|
-
limiter = _get_limiter(path)
|
|
107
|
-
return await self._session._request(http_method, url, params=params, json=json, limit=limiter)
|
|
108
|
-
|
|
109
|
-
async def fetch_all(self, path: str, **kwargs: Any) -> list[Any]:
|
|
110
|
-
"""Fetch all pages. Accepts an optional ``paginator`` kwarg —
|
|
111
|
-
a callable ``(response) -> (items, next_params | None)`` for custom pagination logic.
|
|
112
|
-
|
|
113
|
-
Example::
|
|
114
|
-
|
|
115
|
-
def my_paginator(raw):
|
|
116
|
-
items = raw.get("result", [])
|
|
117
|
-
cursor = raw.get("cursor") or None
|
|
118
|
-
next_params = {"cursor": cursor} if cursor else None
|
|
119
|
-
return items, next_params
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
all_items = await api.get_all(
|
|
123
|
-
"/api/v3/custom", paginator=my_paginator
|
|
124
|
-
)
|
|
125
|
-
"""
|
|
126
|
-
from .exceptions import PaginationNotSupported
|
|
127
|
-
|
|
128
|
-
paginator = kwargs.pop("paginator", None)
|
|
129
|
-
body = kwargs.pop("body", None)
|
|
130
|
-
http_method = "POST" if body is not None else "GET"
|
|
131
|
-
|
|
132
|
-
def _do_request(
|
|
133
|
-
extra: dict[str, Any] | None = None,
|
|
134
|
-
extra_body: dict[str, Any] | None = None,
|
|
135
|
-
full_body: dict[str, Any] | None = None,
|
|
136
|
-
) -> Any:
|
|
137
|
-
if full_body is not None:
|
|
138
|
-
return self.dispatch(http_method, path, params=kwargs or None, json=full_body)
|
|
139
|
-
if body is not None:
|
|
140
|
-
json = {**body, **(extra or {}), **(extra_body or {})}
|
|
141
|
-
return self.dispatch(http_method, path, params=kwargs or None, json=json)
|
|
142
|
-
params = {**kwargs, **(extra or {})} or None
|
|
143
|
-
return self.dispatch(http_method, path, params=params)
|
|
144
|
-
|
|
145
|
-
if paginator is not None:
|
|
146
|
-
result: list[Any] = []
|
|
147
|
-
next_extra: dict[str, Any] = {}
|
|
148
|
-
while True:
|
|
149
|
-
if body is not None:
|
|
150
|
-
json = next_extra if next_extra else body
|
|
151
|
-
raw = await self.dispatch(http_method, path, params=kwargs or None, json=json)
|
|
152
|
-
else:
|
|
153
|
-
raw = await _do_request(next_extra or None)
|
|
154
|
-
items, next_extra = paginator(raw)
|
|
155
|
-
result.extend(items)
|
|
156
|
-
if not next_extra:
|
|
157
|
-
break
|
|
158
|
-
return result
|
|
159
|
-
|
|
160
|
-
from .utils.paginate import PAGINATION_STRATEGIES
|
|
161
|
-
|
|
162
|
-
page_size = _PAGE_SIZES.get(path, 1000)
|
|
163
|
-
first_extra: dict[str, Any] = {"limit": page_size}
|
|
164
|
-
if body is not None:
|
|
165
|
-
first_extra["offset"] = 0
|
|
166
|
-
raw = await _do_request(first_extra)
|
|
167
|
-
page = _extract_list(raw)
|
|
168
|
-
|
|
169
|
-
if page is None:
|
|
170
|
-
raise PaginationNotSupported(f"No list data found in response for {path!r}")
|
|
171
|
-
|
|
172
|
-
result: list[Any] = list(page)
|
|
173
|
-
|
|
174
|
-
for strategy in PAGINATION_STRATEGIES:
|
|
175
|
-
if strategy.detect(raw, page, body, page_size, path):
|
|
176
|
-
return await strategy.paginate(result, page, raw, _do_request, page_size, body)
|
|
177
|
-
|
|
178
|
-
if not result:
|
|
179
|
-
raise PaginationNotSupported(f"{path!r} returned empty first page — pagination not supported")
|
|
180
|
-
return result
|
|
File without changes
|
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
class Headers:
|
|
2
|
-
def __init__(self) -> None:
|
|
3
|
-
self.accept = "application/json;charset=utf-8"
|
|
4
|
-
self.content_type = "application/json"
|
|
5
|
-
self.authorization: str | None = None
|
|
6
|
-
self.user_agent = (
|
|
7
|
-
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
|
8
|
-
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
|
9
|
-
"Chrome/124.0.0.0 Safari/537.36"
|
|
10
|
-
)
|
|
11
|
-
|
|
12
|
-
def set_token(self, token: str) -> None:
|
|
13
|
-
self.authorization = f"Bearer {token}"
|
|
14
|
-
|
|
15
|
-
def as_dict(self) -> dict[str, str]:
|
|
16
|
-
headers: dict[str, str] = {
|
|
17
|
-
"Accept": self.accept,
|
|
18
|
-
"Content-Type": self.content_type,
|
|
19
|
-
"User-Agent": self.user_agent,
|
|
20
|
-
}
|
|
21
|
-
if self.authorization is not None:
|
|
22
|
-
headers["Authorization"] = self.authorization
|
|
23
|
-
return headers
|
|
File without changes
|
|
@@ -1,155 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
from collections.abc import Callable
|
|
4
|
-
from typing import Any
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
_Requester = Callable[..., Any]
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
class PaginationStrategy:
|
|
11
|
-
"""Base class for pagination strategies used by fetch_all."""
|
|
12
|
-
|
|
13
|
-
def detect(self, raw: Any, page: list[Any], body: Any, page_size: int, path: str) -> bool:
|
|
14
|
-
raise NotImplementedError
|
|
15
|
-
|
|
16
|
-
async def paginate(
|
|
17
|
-
self, result: list[Any], page: list[Any], raw: Any, request: _Requester, page_size: int, body: Any
|
|
18
|
-
) -> list[Any]:
|
|
19
|
-
raise NotImplementedError
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
class RrdIdCursorStrategy(PaginationStrategy):
|
|
23
|
-
def detect(self, raw: Any, page: list[Any], body: Any, page_size: int, path: str) -> bool:
|
|
24
|
-
return bool(page and isinstance(page[-1], dict) and "rrd_id" in page[-1])
|
|
25
|
-
|
|
26
|
-
async def paginate(
|
|
27
|
-
self, result: list[Any], page: list[Any], raw: Any, request: _Requester, page_size: int, body: Any
|
|
28
|
-
) -> list[Any]:
|
|
29
|
-
from .._method import _extract_list
|
|
30
|
-
|
|
31
|
-
while page:
|
|
32
|
-
rrd_id = page[-1]["rrd_id"]
|
|
33
|
-
page = _extract_list(await request({"rrdid": rrd_id})) or []
|
|
34
|
-
result.extend(page)
|
|
35
|
-
return result
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
class LastChangeDateStrategy(PaginationStrategy):
|
|
39
|
-
def detect(self, raw: Any, page: list[Any], body: Any, page_size: int, path: str) -> bool:
|
|
40
|
-
return bool(page and isinstance(page[-1], dict) and "lastChangeDate" in page[-1])
|
|
41
|
-
|
|
42
|
-
async def paginate(
|
|
43
|
-
self, result: list[Any], page: list[Any], raw: Any, request: _Requester, page_size: int, body: Any
|
|
44
|
-
) -> list[Any]:
|
|
45
|
-
from .._method import _extract_list
|
|
46
|
-
|
|
47
|
-
while page:
|
|
48
|
-
date_from = page[-1]["lastChangeDate"]
|
|
49
|
-
page = _extract_list(await request({"dateFrom": date_from})) or []
|
|
50
|
-
result.extend(page)
|
|
51
|
-
return result
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
class BodyCursorStrategy(PaginationStrategy):
|
|
55
|
-
def detect(self, raw: Any, page: list[Any], body: Any, page_size: int, path: str) -> bool:
|
|
56
|
-
return body is not None and isinstance(raw, dict) and bool(raw.get("cursor"))
|
|
57
|
-
|
|
58
|
-
async def paginate(
|
|
59
|
-
self, result: list[Any], page: list[Any], raw: Any, request: _Requester, page_size: int, body: Any
|
|
60
|
-
) -> list[Any]:
|
|
61
|
-
from .._method import _extract_list
|
|
62
|
-
|
|
63
|
-
cursor_val = raw.get("cursor")
|
|
64
|
-
while cursor_val:
|
|
65
|
-
raw = await request(extra_body={"cursor": cursor_val})
|
|
66
|
-
page = _extract_list(raw)
|
|
67
|
-
if not page:
|
|
68
|
-
break
|
|
69
|
-
result.extend(page)
|
|
70
|
-
cursor_val = raw.get("cursor") if isinstance(raw, dict) else None
|
|
71
|
-
return result
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
class NextCursorStrategy(PaginationStrategy):
|
|
75
|
-
def detect(self, raw: Any, page: list[Any], body: Any, page_size: int, path: str) -> bool:
|
|
76
|
-
return isinstance(raw, dict) and "next" in raw
|
|
77
|
-
|
|
78
|
-
async def paginate(
|
|
79
|
-
self, result: list[Any], page: list[Any], raw: Any, request: _Requester, page_size: int, body: Any
|
|
80
|
-
) -> list[Any]:
|
|
81
|
-
from .._method import _extract_list
|
|
82
|
-
|
|
83
|
-
cursor = raw["next"]
|
|
84
|
-
while cursor:
|
|
85
|
-
raw = await request({"limit": page_size, "next": cursor})
|
|
86
|
-
page = _extract_list(raw)
|
|
87
|
-
if not page:
|
|
88
|
-
break
|
|
89
|
-
result.extend(page)
|
|
90
|
-
cursor = raw.get("next") if isinstance(raw, dict) else None
|
|
91
|
-
return result
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
class OffsetStrategy(PaginationStrategy):
|
|
95
|
-
def detect(self, raw: Any, page: list[Any], body: Any, page_size: int, path: str) -> bool:
|
|
96
|
-
return len(page) >= page_size
|
|
97
|
-
|
|
98
|
-
async def paginate(
|
|
99
|
-
self, result: list[Any], page: list[Any], raw: Any, request: _Requester, page_size: int, body: Any
|
|
100
|
-
) -> list[Any]:
|
|
101
|
-
from .._method import _extract_list
|
|
102
|
-
|
|
103
|
-
offset = page_size
|
|
104
|
-
while True:
|
|
105
|
-
raw = await request({"limit": page_size, "offset": offset})
|
|
106
|
-
page = _extract_list(raw)
|
|
107
|
-
if not page:
|
|
108
|
-
break
|
|
109
|
-
result.extend(page)
|
|
110
|
-
if len(page) < page_size:
|
|
111
|
-
break
|
|
112
|
-
offset += page_size
|
|
113
|
-
return result
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
class CardsListStrategy(PaginationStrategy):
|
|
117
|
-
path = "/content/v2/get/cards/list"
|
|
118
|
-
|
|
119
|
-
def detect(self, raw: Any, page: list[Any], body: Any, page_size: int, path: str) -> bool:
|
|
120
|
-
if path != self.path:
|
|
121
|
-
return False
|
|
122
|
-
cursor = raw.get("cursor", {}) if isinstance(raw, dict) else {}
|
|
123
|
-
return isinstance(cursor, dict) and "updatedAt" in cursor
|
|
124
|
-
|
|
125
|
-
async def paginate(
|
|
126
|
-
self, result: list[Any], page: list[Any], raw: Any, request: _Requester, page_size: int, body: Any
|
|
127
|
-
) -> list[Any]:
|
|
128
|
-
cursor = raw.get("cursor", {}) if isinstance(raw, dict) else {}
|
|
129
|
-
while cursor.get("total", 0) >= page_size:
|
|
130
|
-
next_body = {
|
|
131
|
-
**body,
|
|
132
|
-
"settings": {
|
|
133
|
-
**body.get("settings", {}),
|
|
134
|
-
"cursor": {
|
|
135
|
-
"limit": page_size,
|
|
136
|
-
"updatedAt": cursor["updatedAt"],
|
|
137
|
-
"nmID": cursor["nmID"],
|
|
138
|
-
},
|
|
139
|
-
},
|
|
140
|
-
}
|
|
141
|
-
raw = await request(full_body=next_body)
|
|
142
|
-
page = raw.get("cards", []) if isinstance(raw, dict) else []
|
|
143
|
-
result.extend(page)
|
|
144
|
-
cursor = raw.get("cursor", {}) if isinstance(raw, dict) else {}
|
|
145
|
-
return result
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
PAGINATION_STRATEGIES: list[PaginationStrategy] = [
|
|
149
|
-
CardsListStrategy(),
|
|
150
|
-
RrdIdCursorStrategy(),
|
|
151
|
-
LastChangeDateStrategy(),
|
|
152
|
-
BodyCursorStrategy(),
|
|
153
|
-
NextCursorStrategy(),
|
|
154
|
-
OffsetStrategy(),
|
|
155
|
-
]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|