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.
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: wbapi-async
3
- Version: 0.7.4
4
- Summary: Asynchronous client for Wildberries API
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
- <p align="center">
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
+ ![Logo](https://dev.wildberries.ru/images/open-graph.png)
47
46
 
48
47
  <div align="center">
49
48
 
50
- #### Lightweight async client for Wildberries Seller API
49
+ #### Fast, lightweight async client for the Wildberries Seller API
51
50
 
52
51
  [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT)
53
- [![PyPI version](https://img.shields.io/pypi/v/wild-api.svg)](https://pypi.org/project/wild-api/)
54
- [![Downloads](https://img.shields.io/pypi/dm/wild-api.svg)](https://pypi.python.org/pypi/wild-api)
52
+ [![PyPI version](https://img.shields.io/pypi/v/wbapi-async.svg)](https://pypi.org/project/wild-api/)
53
+ [![Downloads](https://img.shields.io/pypi/dm/wbapi-async.svg)](https://pypi.python.org/pypi/wild-api)
55
54
  [![Docs](https://img.shields.io/badge/docs-serdukow.github.io-blue.svg)](https://serdukow.github.io/wbapi-async/)
55
+ [![Python](https://img.shields.io/pypi/pyversions/wbapi-async.svg)](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** — 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 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/)
66
- - **Rate limiting** — per-endpoint limits from the spec, powered by `aiolimiter`
67
- - **Auto-retry** — automatic retry on HTTP 429
68
- - **Always up to date** — path registry is [auto-generated](https://github.com/serdukow/wbapi-codegen) from WB OpenAPI specs daily
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
- from wbapi_async import WbAPI
77
+ import asyncio
78
+ from wbapi import WbAPI
81
79
 
82
- async with WbAPI(token="...") as api:
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
- supplies = await api.get_all("/api/v3/supplies")
85
- print(f"supplies: {supplies!r}")
86
- ```
90
+ cards = await api.get_all("/content/v2/get/cards/list", body=body)
91
+ print(f"cards: {cards!r}")
87
92
 
88
- Just copy the path straight from the [WB API](https://dev.wildberries.ru) — the correct base URL and subdomain are resolved automatically.
93
+ asyncio.run(main())
94
+ ```
@@ -0,0 +1,50 @@
1
+ ![Logo](https://dev.wildberries.ru/images/open-graph.png)
2
+
3
+ <div align="center">
4
+
5
+ #### Fast, lightweight async client for the Wildberries Seller API
6
+
7
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT)
8
+ [![PyPI version](https://img.shields.io/pypi/v/wbapi-async.svg)](https://pypi.org/project/wild-api/)
9
+ [![Downloads](https://img.shields.io/pypi/dm/wbapi-async.svg)](https://pypi.python.org/pypi/wild-api)
10
+ [![Docs](https://img.shields.io/badge/docs-serdukow.github.io-blue.svg)](https://serdukow.github.io/wbapi-async/)
11
+ [![Python](https://img.shields.io/pypi/pyversions/wbapi-async.svg)](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.4"
8
- description = "Asynchronous client for Wildberries API"
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/wbapi_async",
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/wbapi_async"]
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 = ["wbapi_async"]
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, WbAPIError
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
- "WbAPIError",
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
- _PATH_TO_BASE: dict[str, str] = {
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
- _PATH_TO_LIMIT: dict[str, tuple[int, int, int, int]] = {
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
- _DEFAULT_LIMIT: tuple[int, int, int, int] = (60000, 60, 1000, 5)
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
- class MethodDispatcher:
535
- def __init__(self, session: BaseSession, token: str) -> None:
536
- self._session = session
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
- Full API reference and available paths: https://dev.wildberries.ru/openapi/
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
- news = await api.get(
54
- "/api/communications/v2/news", fromID=0
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 WbAPIError
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 WbAPIError(http_status=response.status_code, **detail)
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 WbAPIError(
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 BaseWbAPIError(Exception):
4
+ class BaseWBAPIError(Exception):
5
5
  pass
6
6
 
7
7
 
8
- class WbAPIError(BaseWbAPIError):
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(BaseWbAPIError):
16
+ class TokenValidationError(BaseWBAPIError):
17
17
  pass
18
18
 
19
19
 
20
- class PaginationNotSupported(BaseWbAPIError):
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
- raise AttributeError(name) from None
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
+ ]
@@ -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
- [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT)
10
- [![PyPI version](https://img.shields.io/pypi/v/wild-api.svg)](https://pypi.org/project/wild-api/)
11
- [![Downloads](https://img.shields.io/pypi/dm/wild-api.svg)](https://pypi.python.org/pypi/wild-api)
12
- [![Docs](https://img.shields.io/badge/docs-serdukow.github.io-blue.svg)](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