wbapi-async 0.7.0__tar.gz → 0.7.2__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.0 → wbapi_async-0.7.2}/PKG-INFO +13 -30
- {wbapi_async-0.7.0 → wbapi_async-0.7.2}/README.md +12 -29
- {wbapi_async-0.7.0 → wbapi_async-0.7.2}/pyproject.toml +1 -1
- {wbapi_async-0.7.0 → wbapi_async-0.7.2}/src/wbapi_async/api.py +0 -2
- {wbapi_async-0.7.0 → wbapi_async-0.7.2}/src/wbapi_async/client/session/base.py +10 -3
- {wbapi_async-0.7.0 → wbapi_async-0.7.2}/src/wbapi_async/method.py +13 -48
- wbapi_async-0.7.2/src/wbapi_async/utils/paginate.py +114 -0
- wbapi_async-0.7.0/src/wbapi_async/utils/paginate.py +0 -86
- wbapi_async-0.7.0/src/wbapi_async/utils/token.py +0 -63
- wbapi_async-0.7.0/src/wbapi_async/utils/unofficial.py +0 -20
- {wbapi_async-0.7.0 → wbapi_async-0.7.2}/.gitignore +0 -0
- {wbapi_async-0.7.0 → wbapi_async-0.7.2}/LICENSE +0 -0
- {wbapi_async-0.7.0 → wbapi_async-0.7.2}/src/wbapi_async/__init__.py +0 -0
- {wbapi_async-0.7.0 → wbapi_async-0.7.2}/src/wbapi_async/client/__init__.py +0 -0
- {wbapi_async-0.7.0 → wbapi_async-0.7.2}/src/wbapi_async/client/session/__init__.py +0 -0
- {wbapi_async-0.7.0 → wbapi_async-0.7.2}/src/wbapi_async/client/session/headers.py +0 -0
- {wbapi_async-0.7.0 → wbapi_async-0.7.2}/src/wbapi_async/exceptions.py +0 -0
- {wbapi_async-0.7.0 → wbapi_async-0.7.2}/src/wbapi_async/py.typed +0 -0
- {wbapi_async-0.7.0 → wbapi_async-0.7.2}/src/wbapi_async/type.py +0 -0
- {wbapi_async-0.7.0 → wbapi_async-0.7.2}/src/wbapi_async/utils/__init__.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: wbapi-async
|
|
3
|
-
Version: 0.7.
|
|
3
|
+
Version: 0.7.2
|
|
4
4
|
Summary: Asynchronous client for Wildberries API
|
|
5
5
|
Project-URL: Documentation, https://serdukow.github.io/wbapi-async/
|
|
6
6
|
Project-URL: Repository, https://github.com/serdukow/wbapi-async
|
|
@@ -47,7 +47,7 @@ Description-Content-Type: text/markdown
|
|
|
47
47
|
|
|
48
48
|
<div align="center">
|
|
49
49
|
|
|
50
|
-
|
|
50
|
+
#### Lightweight async client for Wildberries Seller API
|
|
51
51
|
|
|
52
52
|
[](https://opensource.org/licenses/MIT)
|
|
53
53
|
[](https://pypi.org/project/wild-api/)
|
|
@@ -59,20 +59,27 @@ Description-Content-Type: text/markdown
|
|
|
59
59
|
Lightweight async Python client for the [Wildberries Seller API](https://dev.wildberries.ru).
|
|
60
60
|
Just pass the path from the docs and get data back.
|
|
61
61
|
|
|
62
|
+
## Features
|
|
63
|
+
|
|
64
|
+
- **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
|
|
65
|
+
- **Auto-pagination** — `get_all()` fetches all page. Auto-detect for all known strategies
|
|
66
|
+
- **Rate limiting** — per-endpoint limits from the spec, powered by `aiolimiter`
|
|
67
|
+
- **Auto-retry** — automatic retry on HTTP 429 with `X-Ratelimit-Retry` backoff
|
|
68
|
+
- **Always up to date** — path registry is [auto-generated](https://github.com/serdukow/wbapi-codegen) from WB OpenAPI specs daily
|
|
69
|
+
- **Fully async** — built on `httpx` + `asyncio`
|
|
70
|
+
|
|
62
71
|
## Install
|
|
63
72
|
|
|
64
73
|
```bash
|
|
65
74
|
pip install wbapi-async
|
|
66
75
|
```
|
|
67
76
|
|
|
68
|
-
##
|
|
77
|
+
## Quick start
|
|
69
78
|
|
|
70
79
|
```python
|
|
71
80
|
from wbapi_async import WbAPI
|
|
72
81
|
|
|
73
82
|
async with WbAPI(token="...") as api:
|
|
74
|
-
orders = await api.get("/api/v3/orders/new", limit=10, next=0)
|
|
75
|
-
print(f"orders: {orders.orders!r}")
|
|
76
83
|
|
|
77
84
|
supplies = await api.get_all("/api/v3/supplies")
|
|
78
85
|
print(f"supplies: {supplies!r}")
|
|
@@ -84,28 +91,4 @@ async with WbAPI(token="...") as api:
|
|
|
84
91
|
await api.delete("/content/v2/tag/99")
|
|
85
92
|
```
|
|
86
93
|
|
|
87
|
-
|
|
88
|
-
no need to know which subdomain serves which endpoint, it's resolved automatically.
|
|
89
|
-
|
|
90
|
-
## Features
|
|
91
|
-
|
|
92
|
-
- **Zero boilerplate** — no type models, no method classes, just `api.get(path, **params)`
|
|
93
|
-
- **Auto-pagination** — `get_all()` fetches all page. Currently auto-detects cursor or offset style
|
|
94
|
-
- **Rate limiting** — per-endpoint limits from the spec, powered by `aiolimiter`
|
|
95
|
-
- **Auto-retry** — automatic retry on HTTP 429 with `X-Ratelimit-Retry` backoff
|
|
96
|
-
- **Always up to date** — path registry is [auto-generated](https://github.com/serdukow/wbapi-codegen) from WB OpenAPI specs daily
|
|
97
|
-
- **Fully async** — built on `httpx` + `asyncio`
|
|
98
|
-
|
|
99
|
-
## Custom pagination
|
|
100
|
-
|
|
101
|
-
For endpoints with non-standard pagination, pass a `paginator=` callable.
|
|
102
|
-
If you've figured out a pattern that works well — feel free to open a [PR](https://github.com/serdukow/wbapi-async/pulls):
|
|
103
|
-
|
|
104
|
-
```python
|
|
105
|
-
def my_paginator(response):
|
|
106
|
-
items = response.get("result", [])
|
|
107
|
-
cursor = response.get("cursor") or None
|
|
108
|
-
return items, {"cursor": cursor} if cursor else None
|
|
109
|
-
|
|
110
|
-
all_items = await api.get_all("/api/v3/custom", paginator=my_paginator)
|
|
111
|
-
```
|
|
94
|
+
Just copy the path straight from the [WB API](https://dev.wildberries.ru) — the correct base URL and subdomain are resolved automatically.
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
<div align="center">
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
#### Lightweight async client for Wildberries Seller API
|
|
8
8
|
|
|
9
9
|
[](https://opensource.org/licenses/MIT)
|
|
10
10
|
[](https://pypi.org/project/wild-api/)
|
|
@@ -16,20 +16,27 @@
|
|
|
16
16
|
Lightweight async Python client for the [Wildberries Seller API](https://dev.wildberries.ru).
|
|
17
17
|
Just pass the path from the docs and get data back.
|
|
18
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 page. Auto-detect for all known strategies
|
|
23
|
+
- **Rate limiting** — per-endpoint limits from the spec, powered by `aiolimiter`
|
|
24
|
+
- **Auto-retry** — automatic retry on HTTP 429 with `X-Ratelimit-Retry` backoff
|
|
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
|
+
|
|
19
28
|
## Install
|
|
20
29
|
|
|
21
30
|
```bash
|
|
22
31
|
pip install wbapi-async
|
|
23
32
|
```
|
|
24
33
|
|
|
25
|
-
##
|
|
34
|
+
## Quick start
|
|
26
35
|
|
|
27
36
|
```python
|
|
28
37
|
from wbapi_async import WbAPI
|
|
29
38
|
|
|
30
39
|
async with WbAPI(token="...") as api:
|
|
31
|
-
orders = await api.get("/api/v3/orders/new", limit=10, next=0)
|
|
32
|
-
print(f"orders: {orders.orders!r}")
|
|
33
40
|
|
|
34
41
|
supplies = await api.get_all("/api/v3/supplies")
|
|
35
42
|
print(f"supplies: {supplies!r}")
|
|
@@ -41,28 +48,4 @@ async with WbAPI(token="...") as api:
|
|
|
41
48
|
await api.delete("/content/v2/tag/99")
|
|
42
49
|
```
|
|
43
50
|
|
|
44
|
-
|
|
45
|
-
no need to know which subdomain serves which endpoint, it's resolved automatically.
|
|
46
|
-
|
|
47
|
-
## Features
|
|
48
|
-
|
|
49
|
-
- **Zero boilerplate** — no type models, no method classes, just `api.get(path, **params)`
|
|
50
|
-
- **Auto-pagination** — `get_all()` fetches all page. Currently auto-detects cursor or offset style
|
|
51
|
-
- **Rate limiting** — per-endpoint limits from the spec, powered by `aiolimiter`
|
|
52
|
-
- **Auto-retry** — automatic retry on HTTP 429 with `X-Ratelimit-Retry` backoff
|
|
53
|
-
- **Always up to date** — path registry is [auto-generated](https://github.com/serdukow/wbapi-codegen) from WB OpenAPI specs daily
|
|
54
|
-
- **Fully async** — built on `httpx` + `asyncio`
|
|
55
|
-
|
|
56
|
-
## Custom pagination
|
|
57
|
-
|
|
58
|
-
For endpoints with non-standard pagination, pass a `paginator=` callable.
|
|
59
|
-
If you've figured out a pattern that works well — feel free to open a [PR](https://github.com/serdukow/wbapi-async/pulls):
|
|
60
|
-
|
|
61
|
-
```python
|
|
62
|
-
def my_paginator(response):
|
|
63
|
-
items = response.get("result", [])
|
|
64
|
-
cursor = response.get("cursor") or None
|
|
65
|
-
return items, {"cursor": cursor} if cursor else None
|
|
66
|
-
|
|
67
|
-
all_items = await api.get_all("/api/v3/custom", paginator=my_paginator)
|
|
68
|
-
```
|
|
51
|
+
Just copy the path straight from the [WB API](https://dev.wildberries.ru) — the correct base URL and subdomain are resolved automatically.
|
|
@@ -5,7 +5,6 @@ from typing import Any
|
|
|
5
5
|
from .client.session.base import BaseSession
|
|
6
6
|
from .method import MethodDispatcher
|
|
7
7
|
from .type import ApiResponse, _wrap
|
|
8
|
-
from .utils.token import validate_token
|
|
9
8
|
|
|
10
9
|
|
|
11
10
|
class WbAPI:
|
|
@@ -38,7 +37,6 @@ class WbAPI:
|
|
|
38
37
|
*,
|
|
39
38
|
timeout: int = 60,
|
|
40
39
|
) -> None:
|
|
41
|
-
validate_token(token)
|
|
42
40
|
self._token = token
|
|
43
41
|
self.session = session or BaseSession(base="https://wildberries.ru", timeout=timeout)
|
|
44
42
|
self._dispatcher = MethodDispatcher(self.session, token)
|
|
@@ -56,7 +56,10 @@ class BaseSession:
|
|
|
56
56
|
raise
|
|
57
57
|
|
|
58
58
|
if response.status_code == 429:
|
|
59
|
-
retry_after = int(response.headers.get("X-Ratelimit-Retry", 1))
|
|
59
|
+
retry_after = int(response.headers.get("X-Ratelimit-Retry", "1"))
|
|
60
|
+
log = logging.getLogger("wbapi.session")
|
|
61
|
+
if retry_after > 60:
|
|
62
|
+
log.warning("Rate limit exceeded. Retrying in %s seconds", retry_after)
|
|
60
63
|
await asyncio.sleep(retry_after)
|
|
61
64
|
return await self._request(method, url, params=params, json=json, limit=limit)
|
|
62
65
|
|
|
@@ -69,10 +72,14 @@ class BaseSession:
|
|
|
69
72
|
|
|
70
73
|
if not response.content:
|
|
71
74
|
return None
|
|
75
|
+
|
|
72
76
|
try:
|
|
73
77
|
return response.json()
|
|
74
|
-
except Exception:
|
|
75
|
-
|
|
78
|
+
except Exception as e:
|
|
79
|
+
raise WbAPIError(
|
|
80
|
+
http_status=response.status_code,
|
|
81
|
+
detail=f"Failed to decode JSON response: {response.text[:200]!r}",
|
|
82
|
+
) from e
|
|
76
83
|
|
|
77
84
|
async def get(
|
|
78
85
|
self,
|
|
@@ -242,7 +242,6 @@ _PATH_TO_BASE: dict[str, str] = {
|
|
|
242
242
|
"/ping": "https://common-api.wildberries.ru",
|
|
243
243
|
}
|
|
244
244
|
|
|
245
|
-
# (period_ms, limit, interval_ms, burst)
|
|
246
245
|
_PATH_TO_LIMIT: dict[str, tuple[int, int, int, int]] = {
|
|
247
246
|
"/adv/v0/auction/nms": (1000, 1, 1000, 1),
|
|
248
247
|
"/adv/v0/auction/placements": (1000, 1, 1000, 1),
|
|
@@ -575,9 +574,9 @@ class MethodDispatcher:
|
|
|
575
574
|
body = kwargs.pop("body", None)
|
|
576
575
|
http_method = "POST" if body is not None else "GET"
|
|
577
576
|
|
|
578
|
-
def _do_request(extra: dict[str, Any] | None = None) -> Any:
|
|
577
|
+
def _do_request(extra: dict[str, Any] | None = None, extra_body: dict[str, Any] | None = None) -> Any:
|
|
579
578
|
if body is not None:
|
|
580
|
-
json = {**body, **(extra or {})}
|
|
579
|
+
json = {**body, **(extra or {}), **(extra_body or {})}
|
|
581
580
|
return self.dispatch(http_method, path, params=kwargs or None, json=json)
|
|
582
581
|
params = {**kwargs, **(extra or {})} or None
|
|
583
582
|
return self.dispatch(http_method, path, params=params)
|
|
@@ -593,7 +592,12 @@ class MethodDispatcher:
|
|
|
593
592
|
break
|
|
594
593
|
return result
|
|
595
594
|
|
|
596
|
-
|
|
595
|
+
from .utils.paginate import PAGINATION_STRATEGIES
|
|
596
|
+
|
|
597
|
+
first_extra: dict[str, Any] = {"limit": _PAGE_SIZE}
|
|
598
|
+
if body is not None:
|
|
599
|
+
first_extra["offset"] = 0
|
|
600
|
+
raw = await _do_request(first_extra)
|
|
597
601
|
page = _extract_list(raw)
|
|
598
602
|
|
|
599
603
|
if page is None:
|
|
@@ -601,49 +605,10 @@ class MethodDispatcher:
|
|
|
601
605
|
|
|
602
606
|
result: list[Any] = list(page)
|
|
603
607
|
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
rrd_id = page[-1]["rrd_id"]
|
|
608
|
-
page = _extract_list(await _do_request({"rrdid": rrd_id})) or []
|
|
609
|
-
result.extend(page)
|
|
610
|
-
return result
|
|
611
|
-
|
|
612
|
-
# lastChangeDate cursor — detected by presence of lastChangeDate in last item
|
|
613
|
-
if page and isinstance(page[-1], dict) and "lastChangeDate" in page[-1]:
|
|
614
|
-
while page:
|
|
615
|
-
date_from = page[-1]["lastChangeDate"]
|
|
616
|
-
page = _extract_list(await _do_request({"dateFrom": date_from})) or []
|
|
617
|
-
result.extend(page)
|
|
618
|
-
return result
|
|
619
|
-
|
|
620
|
-
# Cursor pagination
|
|
621
|
-
if isinstance(raw, dict) and "next" in raw:
|
|
622
|
-
cursor = raw["next"]
|
|
623
|
-
while cursor:
|
|
624
|
-
raw = await _do_request({"limit": _PAGE_SIZE, "next": cursor})
|
|
625
|
-
page = _extract_list(raw)
|
|
626
|
-
if not page:
|
|
627
|
-
break
|
|
628
|
-
result.extend(page)
|
|
629
|
-
cursor = raw.get("next") if isinstance(raw, dict) else None
|
|
630
|
-
return result
|
|
631
|
-
|
|
632
|
-
# Offset pagination — first page was full
|
|
633
|
-
if len(page) < _PAGE_SIZE:
|
|
634
|
-
if not result:
|
|
635
|
-
raise PaginationNotSupported(f"{path!r} returned empty first page — pagination not supported")
|
|
636
|
-
return result
|
|
637
|
-
|
|
638
|
-
offset = _PAGE_SIZE
|
|
639
|
-
while True:
|
|
640
|
-
raw = await _do_request({"limit": _PAGE_SIZE, "offset": offset})
|
|
641
|
-
page = _extract_list(raw)
|
|
642
|
-
if not page:
|
|
643
|
-
break
|
|
644
|
-
result.extend(page)
|
|
645
|
-
if len(page) < _PAGE_SIZE:
|
|
646
|
-
break
|
|
647
|
-
offset += _PAGE_SIZE
|
|
608
|
+
for strategy in PAGINATION_STRATEGIES:
|
|
609
|
+
if strategy.detect(raw, page, body):
|
|
610
|
+
return await strategy.paginate(result, page, raw, _do_request)
|
|
648
611
|
|
|
612
|
+
if not result:
|
|
613
|
+
raise PaginationNotSupported(f"{path!r} returned empty first page — pagination not supported")
|
|
649
614
|
return result
|
|
@@ -0,0 +1,114 @@
|
|
|
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
|
+
]
|
|
@@ -1,86 +0,0 @@
|
|
|
1
|
-
"""paginate() — fetch all pages from a paginated API method.
|
|
2
|
-
|
|
3
|
-
Example::
|
|
4
|
-
|
|
5
|
-
from wbapi_async import WbAPI, paginate
|
|
6
|
-
|
|
7
|
-
async with WbAPI(token="...") as api:
|
|
8
|
-
products = await paginate(
|
|
9
|
-
api.get_products_with_prices, nm_id=123
|
|
10
|
-
)
|
|
11
|
-
orders = await paginate(
|
|
12
|
-
api.get_assembly_orders, date_from=1700000000
|
|
13
|
-
)
|
|
14
|
-
"""
|
|
15
|
-
|
|
16
|
-
from __future__ import annotations
|
|
17
|
-
|
|
18
|
-
from typing import TYPE_CHECKING, Any
|
|
19
|
-
|
|
20
|
-
from pydantic import TypeAdapter
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
if TYPE_CHECKING:
|
|
24
|
-
from ..client.api import WbAPI
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
async def paginate(method: Any, **kwargs: Any) -> list[Any]:
|
|
28
|
-
"""Fetch all pages from a paginated API method and return a combined list.
|
|
29
|
-
|
|
30
|
-
Args:
|
|
31
|
-
method: A bound method from WbAPI (e.g. ``api.get_products_with_prices``).
|
|
32
|
-
**kwargs: Parameters to pass to the method (excluding pagination params).
|
|
33
|
-
|
|
34
|
-
Returns:
|
|
35
|
-
Combined list of all items from all pages.
|
|
36
|
-
|
|
37
|
-
Raises:
|
|
38
|
-
TypeError: If the method does not support pagination.
|
|
39
|
-
|
|
40
|
-
Example::
|
|
41
|
-
|
|
42
|
-
products = await paginate(
|
|
43
|
-
api.get_products_with_prices, nm_id=123
|
|
44
|
-
)
|
|
45
|
-
"""
|
|
46
|
-
from ..methods.pagination import PAGINATION_STRATEGIES # lazy — avoids circular import
|
|
47
|
-
|
|
48
|
-
# Resolve WbAPI instance and WbMethod class from the bound method
|
|
49
|
-
wb_api: WbAPI = method.__self__
|
|
50
|
-
method_cls = getattr(method, "__wrapped_cls__", None)
|
|
51
|
-
if method_cls is None:
|
|
52
|
-
raise TypeError(
|
|
53
|
-
f"{method.__name__!r} does not expose __wrapped_cls__. "
|
|
54
|
-
"Only auto-generated WbAPI methods support paginate()."
|
|
55
|
-
)
|
|
56
|
-
|
|
57
|
-
pagination_key: str | None = method_cls.__dict__.get("__pagination__")
|
|
58
|
-
if not isinstance(pagination_key, str):
|
|
59
|
-
raise TypeError(f"{method_cls.__name__} does not support pagination (__pagination__ is not set)")
|
|
60
|
-
|
|
61
|
-
strategy = PAGINATION_STRATEGIES[pagination_key]
|
|
62
|
-
pagination_params = strategy.first_params()
|
|
63
|
-
|
|
64
|
-
# Use model_construct to skip validation — pagination params are injected separately
|
|
65
|
-
instance = method_cls.model_construct(**kwargs)
|
|
66
|
-
return_type = method_cls.__return__
|
|
67
|
-
adapter: TypeAdapter[Any] = TypeAdapter(list[return_type]) # type: ignore[valid-type]
|
|
68
|
-
|
|
69
|
-
wb_api.session.headers.set_token(wb_api._token)
|
|
70
|
-
url = instance._get_url(wb_api)
|
|
71
|
-
request_limit = getattr(instance, "request_limit", None)
|
|
72
|
-
http_method = getattr(instance, "__http_method__", "GET").upper()
|
|
73
|
-
base_params = instance.model_dump(by_alias=True, exclude_none=True, exclude={"request_limit"})
|
|
74
|
-
result: list[Any] = []
|
|
75
|
-
|
|
76
|
-
while True:
|
|
77
|
-
raw = await instance._dispatch(
|
|
78
|
-
wb_api, http_method, url, {**base_params, **pagination_params}, request_limit
|
|
79
|
-
)
|
|
80
|
-
page_raw = instance._extract(raw)
|
|
81
|
-
if not page_raw:
|
|
82
|
-
return result
|
|
83
|
-
result.extend(adapter.validate_python(page_raw))
|
|
84
|
-
pagination_params = strategy.next_params(pagination_params, raw, page_raw)
|
|
85
|
-
if pagination_params is None:
|
|
86
|
-
return result
|
|
@@ -1,63 +0,0 @@
|
|
|
1
|
-
import base64
|
|
2
|
-
from functools import lru_cache
|
|
3
|
-
import json
|
|
4
|
-
import time
|
|
5
|
-
|
|
6
|
-
from ..exceptions import TokenValidationError
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
def _decode_part(part: str) -> dict:
|
|
10
|
-
padded = part + "=" * (-len(part) % 4)
|
|
11
|
-
try:
|
|
12
|
-
return json.loads(base64.urlsafe_b64decode(padded))
|
|
13
|
-
except Exception as exc:
|
|
14
|
-
raise TokenValidationError("Token is invalid! Failed to decode JWT part.") from exc
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
@lru_cache
|
|
18
|
-
def validate_token(token: str) -> bool:
|
|
19
|
-
"""
|
|
20
|
-
Validate WB API JWT token (RFC 7519).
|
|
21
|
-
|
|
22
|
-
Checks header typ=JWT, required payload fields (id, sid, acc, t, s, exp),
|
|
23
|
-
valid acc value (1-4), and token expiry.
|
|
24
|
-
|
|
25
|
-
Source: https://dev.wildberries.ru/en/docs/openapi/api-information#tag/Authorization/About-the-token
|
|
26
|
-
|
|
27
|
-
:param token: Access token
|
|
28
|
-
:return: True if token is valid
|
|
29
|
-
:raises TokenValidationError: If token is malformed or expired
|
|
30
|
-
"""
|
|
31
|
-
# "test" is a special token that bypasses validation.
|
|
32
|
-
# It exists to allow using unofficial (undocumented) API methods
|
|
33
|
-
# that don't require authorization. Not intended for public use.
|
|
34
|
-
if token == "test":
|
|
35
|
-
return True
|
|
36
|
-
|
|
37
|
-
if not isinstance(token, str) or not token:
|
|
38
|
-
raise TokenValidationError("Token is invalid! Must be a non-empty string.")
|
|
39
|
-
|
|
40
|
-
if any(c.isspace() for c in token):
|
|
41
|
-
raise TokenValidationError("Token is invalid! Must not contain whitespace.")
|
|
42
|
-
|
|
43
|
-
parts = token.split(".")
|
|
44
|
-
if len(parts) != 3:
|
|
45
|
-
raise TokenValidationError(f"Token is invalid! JWT must have 3 parts, got {len(parts)}.")
|
|
46
|
-
|
|
47
|
-
header = _decode_part(parts[0])
|
|
48
|
-
if header.get("typ", "").upper() != "JWT":
|
|
49
|
-
raise TokenValidationError(f"Token is invalid! Header typ must be 'JWT', got {header.get('typ')!r}.")
|
|
50
|
-
|
|
51
|
-
payload = _decode_part(parts[1])
|
|
52
|
-
|
|
53
|
-
for field in ("id", "sid", "acc", "t", "s", "exp"):
|
|
54
|
-
if field not in payload:
|
|
55
|
-
raise TokenValidationError(f"Token is invalid! Payload missing '{field}' field.")
|
|
56
|
-
|
|
57
|
-
if payload["acc"] not in (1, 2, 3, 4):
|
|
58
|
-
raise TokenValidationError(f"Token is invalid! Unknown token type acc={payload['acc']!r}.")
|
|
59
|
-
|
|
60
|
-
if time.time() > payload["exp"]:
|
|
61
|
-
raise TokenValidationError("Token is invalid! Token has expired.")
|
|
62
|
-
|
|
63
|
-
return True
|
|
@@ -1,20 +0,0 @@
|
|
|
1
|
-
from collections.abc import Callable
|
|
2
|
-
from functools import wraps
|
|
3
|
-
from typing import Any
|
|
4
|
-
import warnings
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
def unofficial(func: Callable[..., Any]) -> Callable[..., Any]:
|
|
8
|
-
"""Mark a method as unofficial (not documented in WB API docs)."""
|
|
9
|
-
|
|
10
|
-
@wraps(func)
|
|
11
|
-
async def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
12
|
-
warnings.warn(
|
|
13
|
-
f"{func.__name__} is an unofficial method and may stop working at any time.",
|
|
14
|
-
stacklevel=2,
|
|
15
|
-
)
|
|
16
|
-
return await func(*args, **kwargs)
|
|
17
|
-
|
|
18
|
-
wrapper.__unofficial__ = True # type: ignore[attr-defined]
|
|
19
|
-
wrapper.__doc__ = f".. warning:: Unofficial method — not documented in WB API.\n\n{func.__doc__ or ''}"
|
|
20
|
-
return wrapper
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|