wbapi-async 0.7.4__tar.gz → 0.7.7__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.4 → wbapi_async-0.7.7}/PKG-INFO +28 -22
- wbapi_async-0.7.7/README.md +50 -0
- {wbapi_async-0.7.4 → wbapi_async-0.7.7}/pyproject.toml +7 -5
- {wbapi_async-0.7.4/src/wbapi_async → wbapi_async-0.7.7/src/wbapi}/__init__.py +2 -2
- wbapi_async-0.7.7/src/wbapi/_method.py +180 -0
- wbapi_async-0.7.4/src/wbapi_async/method.py → wbapi_async-0.7.7/src/wbapi/_registry.py +9 -168
- {wbapi_async-0.7.4/src/wbapi_async → wbapi_async-0.7.7/src/wbapi}/api.py +4 -20
- {wbapi_async-0.7.4/src/wbapi_async → wbapi_async-0.7.7/src/wbapi}/client/session/base.py +3 -3
- {wbapi_async-0.7.4/src/wbapi_async → wbapi_async-0.7.7/src/wbapi}/exceptions.py +4 -4
- {wbapi_async-0.7.4/src/wbapi_async → wbapi_async-0.7.7/src/wbapi}/type.py +11 -1
- wbapi_async-0.7.7/src/wbapi/utils/paginate.py +155 -0
- wbapi_async-0.7.4/README.md +0 -45
- wbapi_async-0.7.4/src/wbapi_async/utils/paginate.py +0 -114
- {wbapi_async-0.7.4 → wbapi_async-0.7.7}/.gitignore +0 -0
- {wbapi_async-0.7.4 → wbapi_async-0.7.7}/LICENSE +0 -0
- {wbapi_async-0.7.4/src/wbapi_async → wbapi_async-0.7.7/src/wbapi}/client/__init__.py +0 -0
- {wbapi_async-0.7.4/src/wbapi_async → wbapi_async-0.7.7/src/wbapi}/client/session/__init__.py +0 -0
- {wbapi_async-0.7.4/src/wbapi_async → wbapi_async-0.7.7/src/wbapi}/client/session/headers.py +0 -0
- {wbapi_async-0.7.4/src/wbapi_async → wbapi_async-0.7.7/src/wbapi}/py.typed +0 -0
- {wbapi_async-0.7.4/src/wbapi_async → wbapi_async-0.7.7/src/wbapi}/utils/__init__.py +0 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: wbapi-async
|
|
3
|
-
Version: 0.7.
|
|
4
|
-
Summary:
|
|
3
|
+
Version: 0.7.7
|
|
4
|
+
Summary: Fast, lightweight async client for the Wildberries Seller API
|
|
5
5
|
Project-URL: Documentation, https://serdukow.github.io/wbapi-async/
|
|
6
6
|
Project-URL: Repository, https://github.com/serdukow/wbapi-async
|
|
7
7
|
Project-URL: Changelog, https://github.com/serdukow/wbapi-async/blob/main/CHANGELOG.md
|
|
@@ -17,6 +17,7 @@ Classifier: Framework :: Pydantic :: 2
|
|
|
17
17
|
Classifier: Intended Audience :: Developers
|
|
18
18
|
Classifier: License :: OSI Approved :: MIT License
|
|
19
19
|
Classifier: Programming Language :: Python :: 3
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
20
21
|
Classifier: Programming Language :: Python :: 3.11
|
|
21
22
|
Classifier: Programming Language :: Python :: 3.12
|
|
22
23
|
Classifier: Programming Language :: Python :: 3.13
|
|
@@ -41,31 +42,27 @@ Requires-Dist: pytest<9.0.0,>=8.4.2; extra == 'test'
|
|
|
41
42
|
Requires-Dist: python-dotenv<2.0.0,>=1.2.1; extra == 'test'
|
|
42
43
|
Description-Content-Type: text/markdown
|
|
43
44
|
|
|
44
|
-
|
|
45
|
-
<a href="https://dev.wildberries.ru/"><img src="https://dev.wildberries.ru/images/open-graph.png" alt="WbAPI" width="600"></a>
|
|
46
|
-
</p>
|
|
45
|
+

|
|
47
46
|
|
|
48
47
|
<div align="center">
|
|
49
48
|
|
|
50
|
-
####
|
|
49
|
+
#### Fast, lightweight async client for the Wildberries Seller API
|
|
51
50
|
|
|
52
51
|
[](https://opensource.org/licenses/MIT)
|
|
53
|
-
[](https://pypi.org/project/wild-api/)
|
|
53
|
+
[](https://pypi.python.org/pypi/wild-api)
|
|
55
54
|
[](https://serdukow.github.io/wbapi-async/)
|
|
55
|
+
[](https://pypi.org/project/wbapi-async/)
|
|
56
56
|
|
|
57
57
|
</div>
|
|
58
58
|
|
|
59
|
-
Lightweight async Python client for the [Wildberries Seller API](https://dev.wildberries.ru).
|
|
60
|
-
Just pass the path from the docs and get data back.
|
|
61
|
-
|
|
62
59
|
## Features
|
|
63
60
|
|
|
64
|
-
- **Zero boilerplate** —
|
|
65
|
-
- **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://
|
|
66
|
-
- **Rate limiting** — per-endpoint limits
|
|
67
|
-
- **Auto-retry** — automatic retry
|
|
68
|
-
- **Always up to date** — path registry is
|
|
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
|
|
69
66
|
- **Fully async** — built on `httpx` + `asyncio`
|
|
70
67
|
|
|
71
68
|
## Install
|
|
@@ -77,12 +74,21 @@ pip install wbapi-async
|
|
|
77
74
|
## Quick start
|
|
78
75
|
|
|
79
76
|
```python
|
|
80
|
-
|
|
77
|
+
import asyncio
|
|
78
|
+
from wbapi import WbAPI
|
|
81
79
|
|
|
82
|
-
async
|
|
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
|
+
}
|
|
83
89
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
```
|
|
90
|
+
cards = await api.get_all("/content/v2/get/cards/list", body=body)
|
|
91
|
+
print(f"cards: {cards!r}")
|
|
87
92
|
|
|
88
|
-
|
|
93
|
+
asyncio.run(main())
|
|
94
|
+
```
|
|
@@ -0,0 +1,50 @@
|
|
|
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
|
+
```
|
|
@@ -4,8 +4,9 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "wbapi-async"
|
|
7
|
-
version = "0.7.
|
|
8
|
-
description = "
|
|
7
|
+
version = "0.7.7"
|
|
8
|
+
description = "Fast, lightweight async client for the Wildberries Seller API"
|
|
9
|
+
|
|
9
10
|
authors = [
|
|
10
11
|
{ name = "Andrei Serdiukov", email = "asyncdf@gmail.com" },
|
|
11
12
|
]
|
|
@@ -21,6 +22,7 @@ classifiers = [
|
|
|
21
22
|
"Intended Audience :: Developers",
|
|
22
23
|
"License :: OSI Approved :: MIT License",
|
|
23
24
|
"Programming Language :: Python :: 3",
|
|
25
|
+
"Programming Language :: Python :: 3.10",
|
|
24
26
|
"Programming Language :: Python :: 3.11",
|
|
25
27
|
"Programming Language :: Python :: 3.12",
|
|
26
28
|
"Programming Language :: Python :: 3.13",
|
|
@@ -65,13 +67,13 @@ Issues = "https://github.com/serdukow/wbapi-async/issues"
|
|
|
65
67
|
|
|
66
68
|
[tool.hatch.build.targets.sdist]
|
|
67
69
|
only-include = [
|
|
68
|
-
"src/
|
|
70
|
+
"src/wbapi",
|
|
69
71
|
"README.md",
|
|
70
72
|
"pyproject.toml"
|
|
71
73
|
]
|
|
72
74
|
|
|
73
75
|
[tool.hatch.build.targets.wheel]
|
|
74
|
-
packages = ["src/
|
|
76
|
+
packages = ["src/wbapi"]
|
|
75
77
|
|
|
76
78
|
[tool.ruff]
|
|
77
79
|
exclude = [
|
|
@@ -94,7 +96,7 @@ select = ["E", "F", "I", "UP", "C4", "B", "RUF100"]
|
|
|
94
96
|
fixable = ["ALL"]
|
|
95
97
|
|
|
96
98
|
[tool.ruff.lint.isort]
|
|
97
|
-
known-first-party = ["
|
|
99
|
+
known-first-party = ["wbapi"]
|
|
98
100
|
force-sort-within-sections = true
|
|
99
101
|
order-by-type = true
|
|
100
102
|
combine-as-imports = true
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
from importlib.metadata import version
|
|
2
2
|
|
|
3
3
|
from .api import WbAPI
|
|
4
|
-
from .exceptions import PaginationNotSupported, TokenValidationError,
|
|
4
|
+
from .exceptions import PaginationNotSupported, TokenValidationError, WBAPIError
|
|
5
5
|
from .type import ApiResponse
|
|
6
6
|
|
|
7
7
|
|
|
@@ -9,7 +9,7 @@ __version__ = version("wbapi-async")
|
|
|
9
9
|
|
|
10
10
|
__all__ = (
|
|
11
11
|
"WbAPI",
|
|
12
|
-
"
|
|
12
|
+
"WBAPIError",
|
|
13
13
|
"TokenValidationError",
|
|
14
14
|
"PaginationNotSupported",
|
|
15
15
|
"ApiResponse",
|
|
@@ -0,0 +1,180 @@
|
|
|
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
|
|
@@ -1,17 +1,7 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
from typing import TYPE_CHECKING, Any
|
|
4
|
-
|
|
5
|
-
from aiolimiter import AsyncLimiter
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
if TYPE_CHECKING:
|
|
9
|
-
from .client.session.base import BaseSession
|
|
10
|
-
|
|
11
1
|
# fmt: off
|
|
12
2
|
# AUTO-GENERATED — DO NOT EDIT MANUALLY!!!
|
|
13
3
|
|
|
14
|
-
|
|
4
|
+
_BASES: dict[str, str] = {
|
|
15
5
|
"/adv/v0/auction/nms": "https://advert-api.wildberries.ru",
|
|
16
6
|
"/adv/v0/auction/placements": "https://advert-api.wildberries.ru",
|
|
17
7
|
"/adv/v0/delete": "https://advert-api.wildberries.ru",
|
|
@@ -71,6 +61,7 @@ _PATH_TO_BASE: dict[str, str] = {
|
|
|
71
61
|
"/api/marketplace/v3/supplies/{supplyId}/order-ids": "https://marketplace-api.wildberries.ru",
|
|
72
62
|
"/api/marketplace/v3/supplies/{supplyId}/orders": "https://marketplace-api.wildberries.ru",
|
|
73
63
|
"/api/tariffs/v1/acceptance/coefficients": "https://common-api.wildberries.ru",
|
|
64
|
+
"/api/finance/v1/sales-reports/detailed": "https://finance-api.wildberries.ru",
|
|
74
65
|
"/api/v1/acceptance/options": "https://supplies-api.wildberries.ru",
|
|
75
66
|
"/api/v1/acceptance_report": "https://seller-analytics-api.wildberries.ru",
|
|
76
67
|
"/api/v1/acceptance_report/tasks/{task_id}/download": "https://seller-analytics-api.wildberries.ru",
|
|
@@ -242,7 +233,7 @@ _PATH_TO_BASE: dict[str, str] = {
|
|
|
242
233
|
"/ping": "https://common-api.wildberries.ru",
|
|
243
234
|
}
|
|
244
235
|
|
|
245
|
-
|
|
236
|
+
_LIMITS: dict[str, tuple[int, int, int, int]] = {
|
|
246
237
|
"/adv/v0/auction/nms": (1000, 1, 1000, 1),
|
|
247
238
|
"/adv/v0/auction/placements": (1000, 1, 1000, 1),
|
|
248
239
|
"/adv/v0/delete": (1000, 5, 200, 5),
|
|
@@ -302,6 +293,7 @@ _PATH_TO_LIMIT: dict[str, tuple[int, int, int, int]] = {
|
|
|
302
293
|
"/api/marketplace/v3/supplies/{supplyId}/order-ids": (60000, 300, 200, 20),
|
|
303
294
|
"/api/marketplace/v3/supplies/{supplyId}/orders": (60000, 300, 200, 20),
|
|
304
295
|
"/api/tariffs/v1/acceptance/coefficients": (60000, 6, 10000, 6),
|
|
296
|
+
"/api/finance/v1/sales-reports/detailed": (60000, 1, 60000, 1),
|
|
305
297
|
"/api/v1/acceptance/options": (60000, 6, 10000, 6),
|
|
306
298
|
"/api/v1/acceptance_report": (60000, 1, 60000, 1),
|
|
307
299
|
"/api/v1/acceptance_report/tasks/{task_id}/download": (60000, 1, 60000, 1),
|
|
@@ -462,160 +454,9 @@ _PATH_TO_LIMIT: dict[str, tuple[int, int, int, int]] = {
|
|
|
462
454
|
}
|
|
463
455
|
# fmt: on
|
|
464
456
|
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
# Shared AsyncLimiter instances keyed by (interval_ms, burst).
|
|
468
|
-
_limiters: dict[tuple[int, int], AsyncLimiter] = {}
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
def _get_limiter(path: str) -> AsyncLimiter:
|
|
472
|
-
period_ms, limit, interval_ms, burst = _PATH_TO_LIMIT.get(path, _DEFAULT_LIMIT)
|
|
473
|
-
key = (interval_ms, burst)
|
|
474
|
-
if key not in _limiters:
|
|
475
|
-
_limiters[key] = AsyncLimiter(max_rate=burst, time_period=interval_ms / 1000)
|
|
476
|
-
return _limiters[key]
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
_EXTRA_ALLOWED_HOSTS: frozenset[str] = frozenset(
|
|
480
|
-
{
|
|
481
|
-
"card.wb.ru",
|
|
482
|
-
}
|
|
483
|
-
)
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
def resolve_url(path: str) -> str:
|
|
487
|
-
"""Resolve full URL for a spec path like /api/v3/supplies.
|
|
488
|
-
|
|
489
|
-
If a full URL is passed, it is returned as-is provided its host is a known
|
|
490
|
-
wildberries.ru domain or is listed in ``_EXTRA_ALLOWED_HOSTS``.
|
|
491
|
-
"""
|
|
492
|
-
from urllib.parse import urlparse
|
|
493
|
-
|
|
494
|
-
from .exceptions import WbAPIError
|
|
495
|
-
|
|
496
|
-
if path.startswith("https://") or path.startswith("http://"):
|
|
497
|
-
host = urlparse(path).netloc
|
|
498
|
-
known_hosts = {urlparse(v).netloc for v in _PATH_TO_BASE.values()} | _EXTRA_ALLOWED_HOSTS
|
|
499
|
-
if host not in known_hosts:
|
|
500
|
-
raise WbAPIError(detail=f"Unknown host {host!r}.")
|
|
501
|
-
return path
|
|
502
|
-
|
|
503
|
-
base = _PATH_TO_BASE.get(path)
|
|
504
|
-
if base:
|
|
505
|
-
return base + path
|
|
506
|
-
# Path may contain dynamic segments (e.g. /api/v3/supplies/WB-123/orders).
|
|
507
|
-
# Walk up the path until we find a match.
|
|
508
|
-
parts = path.rstrip("/").split("/")
|
|
509
|
-
for i in range(len(parts) - 1, 0, -1):
|
|
510
|
-
candidate = "/".join(parts[:i])
|
|
511
|
-
if candidate in _PATH_TO_BASE:
|
|
512
|
-
return _PATH_TO_BASE[candidate] + path
|
|
513
|
-
raise WbAPIError(detail=f"Unknown path {path!r}. Check available paths at https://dev.wildberries.ru")
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
_PAGE_SIZE = 1000
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
def _extract_list(raw: Any) -> list[Any] | None:
|
|
520
|
-
if isinstance(raw, list):
|
|
521
|
-
return raw
|
|
522
|
-
if isinstance(raw, dict):
|
|
523
|
-
for v in raw.values():
|
|
524
|
-
if isinstance(v, list):
|
|
525
|
-
return v
|
|
526
|
-
for v in raw.values():
|
|
527
|
-
if isinstance(v, dict):
|
|
528
|
-
found = _extract_list(v)
|
|
529
|
-
if found is not None:
|
|
530
|
-
return found
|
|
531
|
-
return None
|
|
457
|
+
_PUBLIC: frozenset[str] = frozenset({"card.wb.ru"})
|
|
532
458
|
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
self._token = token
|
|
538
|
-
|
|
539
|
-
async def dispatch(
|
|
540
|
-
self,
|
|
541
|
-
http_method: str,
|
|
542
|
-
path: str,
|
|
543
|
-
*,
|
|
544
|
-
params: dict[str, Any] | None = None,
|
|
545
|
-
json: Any | None = None,
|
|
546
|
-
no_auth: bool = False,
|
|
547
|
-
) -> Any:
|
|
548
|
-
from urllib.parse import urlparse
|
|
549
|
-
|
|
550
|
-
if not no_auth and path.startswith(("https://", "http://")):
|
|
551
|
-
no_auth = urlparse(path).netloc in _EXTRA_ALLOWED_HOSTS
|
|
552
|
-
|
|
553
|
-
if no_auth:
|
|
554
|
-
self._session.headers.authorization = None
|
|
555
|
-
else:
|
|
556
|
-
self._session.headers.set_token(self._token)
|
|
557
|
-
url = resolve_url(path)
|
|
558
|
-
limiter = _get_limiter(path)
|
|
559
|
-
return await self._session._request(http_method, url, params=params, json=json, limit=limiter)
|
|
560
|
-
|
|
561
|
-
async def fetch_all(self, path: str, **kwargs: Any) -> list[Any]:
|
|
562
|
-
"""Fetch all pages. Accepts an optional ``paginator`` kwarg —
|
|
563
|
-
a callable ``(response) -> (items, next_params | None)`` for custom pagination logic.
|
|
564
|
-
|
|
565
|
-
Example::
|
|
566
|
-
|
|
567
|
-
def my_paginator(raw):
|
|
568
|
-
items = raw.get("result", [])
|
|
569
|
-
cursor = raw.get("cursor") or None
|
|
570
|
-
next_params = {"cursor": cursor} if cursor else None
|
|
571
|
-
return items, next_params
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
all_items = await api.get_all(
|
|
575
|
-
"/api/v3/custom", paginator=my_paginator
|
|
576
|
-
)
|
|
577
|
-
"""
|
|
578
|
-
from .exceptions import PaginationNotSupported
|
|
579
|
-
|
|
580
|
-
paginator = kwargs.pop("paginator", None)
|
|
581
|
-
body = kwargs.pop("body", None)
|
|
582
|
-
http_method = "POST" if body is not None else "GET"
|
|
583
|
-
|
|
584
|
-
def _do_request(extra: dict[str, Any] | None = None, extra_body: dict[str, Any] | None = None) -> Any:
|
|
585
|
-
if body is not None:
|
|
586
|
-
json = {**body, **(extra or {}), **(extra_body or {})}
|
|
587
|
-
return self.dispatch(http_method, path, params=kwargs or None, json=json)
|
|
588
|
-
params = {**kwargs, **(extra or {})} or None
|
|
589
|
-
return self.dispatch(http_method, path, params=params)
|
|
590
|
-
|
|
591
|
-
if paginator is not None:
|
|
592
|
-
result: list[Any] = []
|
|
593
|
-
next_params: dict[str, Any] = {}
|
|
594
|
-
while True:
|
|
595
|
-
raw = await _do_request(next_params)
|
|
596
|
-
items, next_params = paginator(raw)
|
|
597
|
-
result.extend(items)
|
|
598
|
-
if not next_params:
|
|
599
|
-
break
|
|
600
|
-
return result
|
|
601
|
-
|
|
602
|
-
from .utils.paginate import PAGINATION_STRATEGIES
|
|
603
|
-
|
|
604
|
-
first_extra: dict[str, Any] = {"limit": _PAGE_SIZE}
|
|
605
|
-
if body is not None:
|
|
606
|
-
first_extra["offset"] = 0
|
|
607
|
-
raw = await _do_request(first_extra)
|
|
608
|
-
page = _extract_list(raw)
|
|
609
|
-
|
|
610
|
-
if page is None:
|
|
611
|
-
raise PaginationNotSupported(f"No list data found in response for {path!r}")
|
|
612
|
-
|
|
613
|
-
result: list[Any] = list(page)
|
|
614
|
-
|
|
615
|
-
for strategy in PAGINATION_STRATEGIES:
|
|
616
|
-
if strategy.detect(raw, page, body):
|
|
617
|
-
return await strategy.paginate(result, page, raw, _do_request)
|
|
618
|
-
|
|
619
|
-
if not result:
|
|
620
|
-
raise PaginationNotSupported(f"{path!r} returned empty first page — pagination not supported")
|
|
621
|
-
return result
|
|
459
|
+
_PAGE_SIZES: dict[str, int] = {
|
|
460
|
+
"/content/v2/get/cards/list": 100,
|
|
461
|
+
"/api/v1/documents/list": 50,
|
|
462
|
+
}
|
|
@@ -2,8 +2,8 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
from typing import Any
|
|
4
4
|
|
|
5
|
+
from ._method import MethodDispatcher
|
|
5
6
|
from .client.session.base import BaseSession
|
|
6
|
-
from .method import MethodDispatcher
|
|
7
7
|
from .type import ApiResponse, _wrap
|
|
8
8
|
|
|
9
9
|
|
|
@@ -11,23 +11,7 @@ class WbAPI:
|
|
|
11
11
|
"""
|
|
12
12
|
Async client for the Wildberries Seller API.
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
Swagger specs (YAML): https://dev.wildberries.ru/api/swagger/yaml/en/
|
|
16
|
-
|
|
17
|
-
Swagger specs (YAML) — use these to look up paths, parameters, and response schemas:
|
|
18
|
-
- https://dev.wildberries.ru/api/swagger/yaml/en/01-general.yaml — warehouses, ping
|
|
19
|
-
- https://dev.wildberries.ru/api/swagger/yaml/en/02-products.yaml — cards, stocks, prices, tags
|
|
20
|
-
- https://dev.wildberries.ru/api/swagger/yaml/en/03-orders-fbs.yaml — orders, supplies, shipments (FBS)
|
|
21
|
-
- https://dev.wildberries.ru/api/swagger/yaml/en/04-orders-dbw.yaml — orders DBW
|
|
22
|
-
- https://dev.wildberries.ru/api/swagger/yaml/en/05-orders-dbs.yaml — orders DBS
|
|
23
|
-
- https://dev.wildberries.ru/api/swagger/yaml/en/06-in-store-pickup.yaml — in-store pickup
|
|
24
|
-
- https://dev.wildberries.ru/api/swagger/yaml/en/07-orders-fbw.yaml — orders FBW
|
|
25
|
-
- https://dev.wildberries.ru/api/swagger/yaml/en/08-promotion.yaml — ad campaigns, budgets, bids
|
|
26
|
-
- https://dev.wildberries.ru/api/swagger/yaml/en/09-communications.yaml — questions, reviews, news
|
|
27
|
-
- https://dev.wildberries.ru/api/swagger/yaml/en/10-tariffs.yaml — tariffs
|
|
28
|
-
- https://dev.wildberries.ru/api/swagger/yaml/en/11-analytics.yaml — sales funnel, turnover, stocks
|
|
29
|
-
- https://dev.wildberries.ru/api/swagger/yaml/en/12-reports.yaml — detailed sales report
|
|
30
|
-
- https://dev.wildberries.ru/api/swagger/yaml/en/13-finances.yaml — payments, penalties
|
|
14
|
+
Source: https://dev.wildberries.ru/en/docs/openapi/api-information
|
|
31
15
|
"""
|
|
32
16
|
|
|
33
17
|
def __init__(
|
|
@@ -50,8 +34,8 @@ class WbAPI:
|
|
|
50
34
|
orders = await api.get(
|
|
51
35
|
"/api/v3/orders/new", limit=10, next=0
|
|
52
36
|
)
|
|
53
|
-
|
|
54
|
-
"/api/
|
|
37
|
+
meta = await api.get(
|
|
38
|
+
"/api/v3/orders/{orderId}/meta", orderId=123
|
|
55
39
|
)
|
|
56
40
|
"""
|
|
57
41
|
return _wrap(await self._dispatcher.dispatch("GET", path, params=kwargs or None))
|
|
@@ -7,7 +7,7 @@ from aiolimiter import AsyncLimiter
|
|
|
7
7
|
import httpx
|
|
8
8
|
from httpx import RequestError
|
|
9
9
|
|
|
10
|
-
from ...exceptions import
|
|
10
|
+
from ...exceptions import WBAPIError
|
|
11
11
|
from .headers import Headers
|
|
12
12
|
|
|
13
13
|
|
|
@@ -74,7 +74,7 @@ class BaseSession:
|
|
|
74
74
|
detail = response.json() if response.content else {}
|
|
75
75
|
except Exception:
|
|
76
76
|
detail = {"detail": response.text.strip() or None}
|
|
77
|
-
raise
|
|
77
|
+
raise WBAPIError(http_status=response.status_code, **detail)
|
|
78
78
|
|
|
79
79
|
if not response.content:
|
|
80
80
|
return None
|
|
@@ -82,7 +82,7 @@ class BaseSession:
|
|
|
82
82
|
try:
|
|
83
83
|
return response.json()
|
|
84
84
|
except Exception as e:
|
|
85
|
-
raise
|
|
85
|
+
raise WBAPIError(
|
|
86
86
|
http_status=response.status_code,
|
|
87
87
|
detail=f"Failed to decode JSON response: {response.text[:200]!r}",
|
|
88
88
|
) from e
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
from typing import Any
|
|
2
2
|
|
|
3
3
|
|
|
4
|
-
class
|
|
4
|
+
class BaseWBAPIError(Exception):
|
|
5
5
|
pass
|
|
6
6
|
|
|
7
7
|
|
|
8
|
-
class
|
|
8
|
+
class WBAPIError(BaseWBAPIError):
|
|
9
9
|
def __init__(self, http_status: int = 0, **kwargs: Any) -> None:
|
|
10
10
|
self.http_status = http_status
|
|
11
11
|
self.detail: dict[str, Any] = kwargs
|
|
@@ -13,9 +13,9 @@ class WbAPIError(BaseWbAPIError):
|
|
|
13
13
|
super().__init__(msg)
|
|
14
14
|
|
|
15
15
|
|
|
16
|
-
class TokenValidationError(
|
|
16
|
+
class TokenValidationError(BaseWBAPIError):
|
|
17
17
|
pass
|
|
18
18
|
|
|
19
19
|
|
|
20
|
-
class PaginationNotSupported(
|
|
20
|
+
class PaginationNotSupported(BaseWBAPIError):
|
|
21
21
|
pass
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import re
|
|
3
4
|
from typing import Any
|
|
4
5
|
|
|
5
6
|
|
|
@@ -17,7 +18,8 @@ class ApiResponse:
|
|
|
17
18
|
try:
|
|
18
19
|
val = self._data[name]
|
|
19
20
|
except (KeyError, TypeError):
|
|
20
|
-
|
|
21
|
+
available = list(self._data) if isinstance(self._data, dict) else type(self._data).__name__
|
|
22
|
+
raise AttributeError(f"{name!r} not found. Got: {available}") from None
|
|
21
23
|
return _wrap(val)
|
|
22
24
|
|
|
23
25
|
def __getitem__(self, key: str | int) -> Any:
|
|
@@ -39,3 +41,11 @@ class ApiResponse:
|
|
|
39
41
|
|
|
40
42
|
def unwrap(self) -> Any:
|
|
41
43
|
return self._data
|
|
44
|
+
|
|
45
|
+
def to_snake(self) -> dict[str, Any]:
|
|
46
|
+
def convert(key: str) -> str:
|
|
47
|
+
return re.sub(r"(?<=[a-z0-9])(?=[A-Z])", "_", key).lower()
|
|
48
|
+
|
|
49
|
+
if isinstance(self._data, dict):
|
|
50
|
+
return {convert(k): v for k, v in self._data.items()}
|
|
51
|
+
return self._data
|
|
@@ -0,0 +1,155 @@
|
|
|
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
|
+
]
|
wbapi_async-0.7.4/README.md
DELETED
|
@@ -1,45 +0,0 @@
|
|
|
1
|
-
<p align="center">
|
|
2
|
-
<a href="https://dev.wildberries.ru/"><img src="https://dev.wildberries.ru/images/open-graph.png" alt="WbAPI" width="600"></a>
|
|
3
|
-
</p>
|
|
4
|
-
|
|
5
|
-
<div align="center">
|
|
6
|
-
|
|
7
|
-
#### Lightweight async client for Wildberries Seller API
|
|
8
|
-
|
|
9
|
-
[](https://opensource.org/licenses/MIT)
|
|
10
|
-
[](https://pypi.org/project/wild-api/)
|
|
11
|
-
[](https://pypi.python.org/pypi/wild-api)
|
|
12
|
-
[](https://serdukow.github.io/wbapi-async/)
|
|
13
|
-
|
|
14
|
-
</div>
|
|
15
|
-
|
|
16
|
-
Lightweight async Python client for the [Wildberries Seller API](https://dev.wildberries.ru).
|
|
17
|
-
Just pass the path from the docs and get data back.
|
|
18
|
-
|
|
19
|
-
## Features
|
|
20
|
-
|
|
21
|
-
- **Zero boilerplate** — WB API changes constantly; typed models break on every schema update. Instead, responses are plain attribute-accessible dicts — `result.fieldName` just works, no models to maintain
|
|
22
|
-
- **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://serdukow.github.io/wbapi-async/advanced/pagination/)
|
|
23
|
-
- **Rate limiting** — per-endpoint limits from the spec, powered by `aiolimiter`
|
|
24
|
-
- **Auto-retry** — automatic retry on HTTP 429
|
|
25
|
-
- **Always up to date** — path registry is [auto-generated](https://github.com/serdukow/wbapi-codegen) from WB OpenAPI specs daily
|
|
26
|
-
- **Fully async** — built on `httpx` + `asyncio`
|
|
27
|
-
|
|
28
|
-
## Install
|
|
29
|
-
|
|
30
|
-
```bash
|
|
31
|
-
pip install wbapi-async
|
|
32
|
-
```
|
|
33
|
-
|
|
34
|
-
## Quick start
|
|
35
|
-
|
|
36
|
-
```python
|
|
37
|
-
from wbapi_async import WbAPI
|
|
38
|
-
|
|
39
|
-
async with WbAPI(token="...") as api:
|
|
40
|
-
|
|
41
|
-
supplies = await api.get_all("/api/v3/supplies")
|
|
42
|
-
print(f"supplies: {supplies!r}")
|
|
43
|
-
```
|
|
44
|
-
|
|
45
|
-
Just copy the path straight from the [WB API](https://dev.wildberries.ru) — the correct base URL and subdomain are resolved automatically.
|
|
@@ -1,114 +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) -> bool:
|
|
14
|
-
"""Return True if this strategy applies to the given response."""
|
|
15
|
-
raise NotImplementedError
|
|
16
|
-
|
|
17
|
-
async def paginate(self, result: list[Any], page: list[Any], raw: Any, request: _Requester) -> list[Any]:
|
|
18
|
-
"""Continue fetching pages and append to result. Return final list."""
|
|
19
|
-
raise NotImplementedError
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
class RrdIdCursorStrategy(PaginationStrategy):
|
|
23
|
-
def detect(self, raw: Any, page: list[Any], body: Any) -> bool:
|
|
24
|
-
return bool(page and isinstance(page[-1], dict) and "rrd_id" in page[-1])
|
|
25
|
-
|
|
26
|
-
async def paginate(self, result: list[Any], page: list[Any], raw: Any, request: _Requester) -> list[Any]:
|
|
27
|
-
from ..method import _extract_list
|
|
28
|
-
|
|
29
|
-
while page:
|
|
30
|
-
rrd_id = page[-1]["rrd_id"]
|
|
31
|
-
page = _extract_list(await request({"rrdid": rrd_id})) or []
|
|
32
|
-
result.extend(page)
|
|
33
|
-
return result
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
class LastChangeDateStrategy(PaginationStrategy):
|
|
37
|
-
def detect(self, raw: Any, page: list[Any], body: Any) -> bool:
|
|
38
|
-
return bool(page and isinstance(page[-1], dict) and "lastChangeDate" in page[-1])
|
|
39
|
-
|
|
40
|
-
async def paginate(self, result: list[Any], page: list[Any], raw: Any, request: _Requester) -> list[Any]:
|
|
41
|
-
from ..method import _extract_list
|
|
42
|
-
|
|
43
|
-
while page:
|
|
44
|
-
date_from = page[-1]["lastChangeDate"]
|
|
45
|
-
page = _extract_list(await request({"dateFrom": date_from})) or []
|
|
46
|
-
result.extend(page)
|
|
47
|
-
return result
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
class BodyCursorStrategy(PaginationStrategy):
|
|
51
|
-
def detect(self, raw: Any, page: list[Any], body: Any) -> bool:
|
|
52
|
-
return body is not None and isinstance(raw, dict) and bool(raw.get("cursor"))
|
|
53
|
-
|
|
54
|
-
async def paginate(self, result: list[Any], page: list[Any], raw: Any, request: _Requester) -> list[Any]:
|
|
55
|
-
from ..method import _extract_list
|
|
56
|
-
|
|
57
|
-
cursor_val = raw.get("cursor")
|
|
58
|
-
while cursor_val:
|
|
59
|
-
raw = await request(extra_body={"cursor": cursor_val})
|
|
60
|
-
page = _extract_list(raw)
|
|
61
|
-
if not page:
|
|
62
|
-
break
|
|
63
|
-
result.extend(page)
|
|
64
|
-
cursor_val = raw.get("cursor") if isinstance(raw, dict) else None
|
|
65
|
-
return result
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
class NextCursorStrategy(PaginationStrategy):
|
|
69
|
-
def detect(self, raw: Any, page: list[Any], body: Any) -> bool:
|
|
70
|
-
return isinstance(raw, dict) and "next" in raw
|
|
71
|
-
|
|
72
|
-
async def paginate(self, result: list[Any], page: list[Any], raw: Any, request: _Requester) -> list[Any]:
|
|
73
|
-
from ..method import _PAGE_SIZE, _extract_list
|
|
74
|
-
|
|
75
|
-
cursor = raw["next"]
|
|
76
|
-
while cursor:
|
|
77
|
-
raw = await request({"limit": _PAGE_SIZE, "next": cursor})
|
|
78
|
-
page = _extract_list(raw)
|
|
79
|
-
if not page:
|
|
80
|
-
break
|
|
81
|
-
result.extend(page)
|
|
82
|
-
cursor = raw.get("next") if isinstance(raw, dict) else None
|
|
83
|
-
return result
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
class OffsetStrategy(PaginationStrategy):
|
|
87
|
-
def detect(self, raw: Any, page: list[Any], body: Any) -> bool:
|
|
88
|
-
from ..method import _PAGE_SIZE
|
|
89
|
-
|
|
90
|
-
return len(page) >= _PAGE_SIZE
|
|
91
|
-
|
|
92
|
-
async def paginate(self, result: list[Any], page: list[Any], raw: Any, request: _Requester) -> list[Any]:
|
|
93
|
-
from ..method import _PAGE_SIZE, _extract_list
|
|
94
|
-
|
|
95
|
-
offset = _PAGE_SIZE
|
|
96
|
-
while True:
|
|
97
|
-
raw = await request({"limit": _PAGE_SIZE, "offset": offset})
|
|
98
|
-
page = _extract_list(raw)
|
|
99
|
-
if not page:
|
|
100
|
-
break
|
|
101
|
-
result.extend(page)
|
|
102
|
-
if len(page) < _PAGE_SIZE:
|
|
103
|
-
break
|
|
104
|
-
offset += _PAGE_SIZE
|
|
105
|
-
return result
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
PAGINATION_STRATEGIES: list[PaginationStrategy] = [
|
|
109
|
-
RrdIdCursorStrategy(),
|
|
110
|
-
LastChangeDateStrategy(),
|
|
111
|
-
BodyCursorStrategy(),
|
|
112
|
-
NextCursorStrategy(),
|
|
113
|
-
OffsetStrategy(),
|
|
114
|
-
]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{wbapi_async-0.7.4/src/wbapi_async → wbapi_async-0.7.7/src/wbapi}/client/session/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|