plentymarkets-api-client 0.1.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- plentymarkets/__init__.py +19 -0
- plentymarkets/_exceptions.py +31 -0
- plentymarkets/_session.py +240 -0
- plentymarkets/_types.py +277 -0
- plentymarkets/client.py +96 -0
- plentymarkets/modules/__init__.py +0 -0
- plentymarkets/modules/contacts.py +230 -0
- plentymarkets/modules/items.py +179 -0
- plentymarkets/modules/orders.py +203 -0
- plentymarkets/modules/stock.py +119 -0
- plentymarkets/py.typed +0 -0
- plentymarkets_api_client-0.1.2.dist-info/METADATA +153 -0
- plentymarkets_api_client-0.1.2.dist-info/RECORD +15 -0
- plentymarkets_api_client-0.1.2.dist-info/WHEEL +4 -0
- plentymarkets_api_client-0.1.2.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from .client import PlentyClient
|
|
2
|
+
from ._types import (
|
|
3
|
+
ContactCreatePayload,
|
|
4
|
+
ContactUpdatePayload,
|
|
5
|
+
ItemCreatePayload,
|
|
6
|
+
ItemUpdatePayload,
|
|
7
|
+
OrderCreatePayload,
|
|
8
|
+
StockCorrectionPayload,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"PlentyClient",
|
|
13
|
+
"OrderCreatePayload",
|
|
14
|
+
"ItemCreatePayload",
|
|
15
|
+
"ItemUpdatePayload",
|
|
16
|
+
"StockCorrectionPayload",
|
|
17
|
+
"ContactCreatePayload",
|
|
18
|
+
"ContactUpdatePayload",
|
|
19
|
+
]
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
class PlentyError(Exception):
|
|
2
|
+
"""Base exception for all plentymarkets client errors."""
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class AuthError(PlentyError):
|
|
6
|
+
"""Raised on 401 responses or when login credentials are rejected."""
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class NotFoundError(PlentyError):
|
|
10
|
+
"""Raised on 404 responses."""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class RateLimitError(PlentyError):
|
|
14
|
+
"""Raised on 429 responses.
|
|
15
|
+
|
|
16
|
+
Attributes:
|
|
17
|
+
retry_after: Seconds to wait before retrying, taken from the
|
|
18
|
+
``Retry-After`` response header. ``0`` if the header is absent.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(self, message: str, retry_after: int = 0) -> None:
|
|
22
|
+
super().__init__(message)
|
|
23
|
+
self.retry_after = retry_after
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ServerError(PlentyError):
|
|
27
|
+
"""Raised on 5xx responses."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ConnectionError(PlentyError):
|
|
31
|
+
"""Raised when the network is unreachable, DNS fails, or a timeout occurs."""
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime, timedelta, timezone
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
_JsonDict = dict[str, Any]
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from ._exceptions import (
|
|
11
|
+
AuthError,
|
|
12
|
+
ConnectionError,
|
|
13
|
+
NotFoundError,
|
|
14
|
+
PlentyError,
|
|
15
|
+
RateLimitError,
|
|
16
|
+
ServerError,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
_REFRESH_BUFFER_SECONDS = 60
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class _Session:
|
|
23
|
+
"""Manages the httpx client, login state, and token lifecycle.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
base_url: PlentyMarkets shop base URL, e.g. ``https://your-shop.plentymarkets.com``.
|
|
27
|
+
username: PlentyMarkets back-end user e-mail.
|
|
28
|
+
password: PlentyMarkets back-end user password.
|
|
29
|
+
client: Shared ``httpx.AsyncClient`` instance.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(
|
|
33
|
+
self,
|
|
34
|
+
base_url: str,
|
|
35
|
+
username: str,
|
|
36
|
+
password: str,
|
|
37
|
+
client: httpx.AsyncClient,
|
|
38
|
+
) -> None:
|
|
39
|
+
stripped = base_url.rstrip("/")
|
|
40
|
+
if "/rest" in stripped.split("//", 1)[-1]:
|
|
41
|
+
raise ValueError(
|
|
42
|
+
f"base_url must be the shop root without '/rest', got: {base_url!r}. "
|
|
43
|
+
"Example: 'https://your-shop.plentymarkets.com'"
|
|
44
|
+
)
|
|
45
|
+
self._base_url = stripped
|
|
46
|
+
self._username = username
|
|
47
|
+
self._password = password
|
|
48
|
+
self._client = client
|
|
49
|
+
self._access_token: str | None = None
|
|
50
|
+
self._token_expiry: datetime | None = None
|
|
51
|
+
|
|
52
|
+
@staticmethod
|
|
53
|
+
def _raise_for_status(response: httpx.Response, path: str = "/rest/login") -> None:
|
|
54
|
+
"""Map HTTP error responses to PlentyError subclasses.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
response: The httpx response to check.
|
|
58
|
+
path: Path string used in error messages.
|
|
59
|
+
|
|
60
|
+
Raises:
|
|
61
|
+
AuthError: On 401 responses.
|
|
62
|
+
NotFoundError: On 404 responses.
|
|
63
|
+
RateLimitError: On 429 responses.
|
|
64
|
+
ServerError: On 5xx responses.
|
|
65
|
+
"""
|
|
66
|
+
if response.status_code == 401:
|
|
67
|
+
raise AuthError(f"Authentication failed: {path}")
|
|
68
|
+
if response.status_code == 404:
|
|
69
|
+
raise NotFoundError(f"{path} returned 404")
|
|
70
|
+
if response.status_code == 429:
|
|
71
|
+
retry_after = int(response.headers.get("Retry-After", 0))
|
|
72
|
+
raise RateLimitError("Rate limit exceeded", retry_after=retry_after)
|
|
73
|
+
if response.status_code >= 500:
|
|
74
|
+
raise ServerError(f"Server error {response.status_code}: {path}")
|
|
75
|
+
|
|
76
|
+
def _is_token_expiring(self) -> bool:
|
|
77
|
+
"""Return True if the token is absent or within the refresh buffer window.
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
True when re-authentication is required before the next request.
|
|
81
|
+
"""
|
|
82
|
+
if self._access_token is None or self._token_expiry is None:
|
|
83
|
+
return True
|
|
84
|
+
return datetime.now(tz=timezone.utc) >= self._token_expiry - timedelta(
|
|
85
|
+
seconds=_REFRESH_BUFFER_SECONDS
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
async def login(self) -> None:
|
|
89
|
+
"""Authenticate and store the access token.
|
|
90
|
+
|
|
91
|
+
Raises:
|
|
92
|
+
AuthError: If the server rejects the credentials (HTTP 401).
|
|
93
|
+
ServerError: On 5xx responses from the login endpoint.
|
|
94
|
+
RateLimitError: On 429 responses from the login endpoint.
|
|
95
|
+
ConnectionError: On network failures reaching the login endpoint.
|
|
96
|
+
"""
|
|
97
|
+
try:
|
|
98
|
+
response = await self._client.post(
|
|
99
|
+
f"{self._base_url}/rest/login",
|
|
100
|
+
json={"username": self._username, "password": self._password},
|
|
101
|
+
)
|
|
102
|
+
except (httpx.ConnectError, httpx.TimeoutException) as exc:
|
|
103
|
+
raise ConnectionError(str(exc)) from exc
|
|
104
|
+
self._raise_for_status(response, "/rest/login")
|
|
105
|
+
if response.status_code >= 400:
|
|
106
|
+
raise PlentyError(f"Login error {response.status_code}")
|
|
107
|
+
data = response.json()
|
|
108
|
+
self._access_token = data["accessToken"]
|
|
109
|
+
self._token_expiry = datetime.now(tz=timezone.utc) + timedelta(
|
|
110
|
+
seconds=data["expiresIn"]
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
async def logout(self) -> None:
|
|
114
|
+
"""Invalidate the current session token on the server and clear local state.
|
|
115
|
+
|
|
116
|
+
If there is no active token, this method is a no-op and makes no network
|
|
117
|
+
calls. Local state is always cleared, even if the DELETE request fails.
|
|
118
|
+
"""
|
|
119
|
+
if self._access_token is None:
|
|
120
|
+
return
|
|
121
|
+
try:
|
|
122
|
+
await self._client.delete(
|
|
123
|
+
f"{self._base_url}/rest/login",
|
|
124
|
+
headers={"Authorization": f"Bearer {self._access_token}"},
|
|
125
|
+
)
|
|
126
|
+
finally:
|
|
127
|
+
self._access_token = None
|
|
128
|
+
self._token_expiry = None
|
|
129
|
+
|
|
130
|
+
async def _request(self, method: str, path: str, **kwargs: Any) -> _JsonDict:
|
|
131
|
+
"""Send an authenticated HTTP request, refreshing the token if needed.
|
|
132
|
+
|
|
133
|
+
Args:
|
|
134
|
+
method: HTTP method (``GET``, ``POST``, ``PUT``, ``DELETE``).
|
|
135
|
+
path: URL path appended to ``base_url``, e.g. ``/rest/orders/1``.
|
|
136
|
+
**kwargs: Extra arguments forwarded to ``httpx.AsyncClient.request``.
|
|
137
|
+
|
|
138
|
+
Returns:
|
|
139
|
+
Parsed JSON response body, or ``{}`` for empty responses (HTTP 204).
|
|
140
|
+
|
|
141
|
+
Raises:
|
|
142
|
+
AuthError: On persistent 401 after one re-login attempt.
|
|
143
|
+
NotFoundError: On HTTP 404.
|
|
144
|
+
RateLimitError: On HTTP 429.
|
|
145
|
+
ServerError: On HTTP 5xx.
|
|
146
|
+
ConnectionError: On network-level failures.
|
|
147
|
+
"""
|
|
148
|
+
if self._is_token_expiring():
|
|
149
|
+
await self.login()
|
|
150
|
+
|
|
151
|
+
headers = kwargs.pop("headers", {})
|
|
152
|
+
headers["Authorization"] = f"Bearer {self._access_token}"
|
|
153
|
+
|
|
154
|
+
try:
|
|
155
|
+
response = await self._client.request(
|
|
156
|
+
method,
|
|
157
|
+
f"{self._base_url}{path}",
|
|
158
|
+
headers=headers,
|
|
159
|
+
**kwargs,
|
|
160
|
+
)
|
|
161
|
+
except (httpx.ConnectError, httpx.TimeoutException) as exc:
|
|
162
|
+
raise ConnectionError(str(exc)) from exc
|
|
163
|
+
|
|
164
|
+
if response.status_code == 401:
|
|
165
|
+
await self.login()
|
|
166
|
+
headers["Authorization"] = f"Bearer {self._access_token}"
|
|
167
|
+
try:
|
|
168
|
+
response = await self._client.request(
|
|
169
|
+
method,
|
|
170
|
+
f"{self._base_url}{path}",
|
|
171
|
+
headers=headers,
|
|
172
|
+
**kwargs,
|
|
173
|
+
)
|
|
174
|
+
except (httpx.ConnectError, httpx.TimeoutException) as exc:
|
|
175
|
+
raise ConnectionError(str(exc)) from exc
|
|
176
|
+
if response.status_code == 401:
|
|
177
|
+
raise AuthError("Authentication failed after token refresh")
|
|
178
|
+
|
|
179
|
+
if response.status_code == 404:
|
|
180
|
+
raise NotFoundError(f"{method} {path} returned 404")
|
|
181
|
+
if response.status_code == 429:
|
|
182
|
+
retry_after = int(response.headers.get("Retry-After", 0))
|
|
183
|
+
raise RateLimitError("Rate limit exceeded", retry_after=retry_after)
|
|
184
|
+
if response.status_code >= 500:
|
|
185
|
+
raise ServerError(f"Server error {response.status_code}: {method} {path}")
|
|
186
|
+
if response.status_code >= 400:
|
|
187
|
+
raise PlentyError(f"Client error {response.status_code}: {method} {path}")
|
|
188
|
+
|
|
189
|
+
response.raise_for_status()
|
|
190
|
+
if not response.content:
|
|
191
|
+
return {}
|
|
192
|
+
return response.json()
|
|
193
|
+
|
|
194
|
+
async def get(self, path: str, **kwargs: Any) -> _JsonDict:
|
|
195
|
+
"""Send an authenticated GET request.
|
|
196
|
+
|
|
197
|
+
Args:
|
|
198
|
+
path: URL path appended to ``base_url``.
|
|
199
|
+
**kwargs: Extra arguments forwarded to ``httpx.AsyncClient.request``.
|
|
200
|
+
|
|
201
|
+
Returns:
|
|
202
|
+
Parsed JSON response body.
|
|
203
|
+
"""
|
|
204
|
+
return await self._request("GET", path, **kwargs)
|
|
205
|
+
|
|
206
|
+
async def post(self, path: str, **kwargs: Any) -> _JsonDict:
|
|
207
|
+
"""Send an authenticated POST request.
|
|
208
|
+
|
|
209
|
+
Args:
|
|
210
|
+
path: URL path appended to ``base_url``.
|
|
211
|
+
**kwargs: Extra arguments forwarded to ``httpx.AsyncClient.request``.
|
|
212
|
+
|
|
213
|
+
Returns:
|
|
214
|
+
Parsed JSON response body.
|
|
215
|
+
"""
|
|
216
|
+
return await self._request("POST", path, **kwargs)
|
|
217
|
+
|
|
218
|
+
async def put(self, path: str, **kwargs: Any) -> _JsonDict:
|
|
219
|
+
"""Send an authenticated PUT request.
|
|
220
|
+
|
|
221
|
+
Args:
|
|
222
|
+
path: URL path appended to ``base_url``.
|
|
223
|
+
**kwargs: Extra arguments forwarded to ``httpx.AsyncClient.request``.
|
|
224
|
+
|
|
225
|
+
Returns:
|
|
226
|
+
Parsed JSON response body.
|
|
227
|
+
"""
|
|
228
|
+
return await self._request("PUT", path, **kwargs)
|
|
229
|
+
|
|
230
|
+
async def delete(self, path: str, **kwargs: Any) -> _JsonDict:
|
|
231
|
+
"""Send an authenticated DELETE request.
|
|
232
|
+
|
|
233
|
+
Args:
|
|
234
|
+
path: URL path appended to ``base_url``.
|
|
235
|
+
**kwargs: Extra arguments forwarded to ``httpx.AsyncClient.request``.
|
|
236
|
+
|
|
237
|
+
Returns:
|
|
238
|
+
Parsed JSON response body.
|
|
239
|
+
"""
|
|
240
|
+
return await self._request("DELETE", path, **kwargs)
|
plentymarkets/_types.py
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
"""TypedDict definitions for PlentyMarkets API request payloads.
|
|
2
|
+
|
|
3
|
+
Import these when constructing payloads to get IDE autocomplete and
|
|
4
|
+
static type checking. At runtime they are plain dicts — no overhead.
|
|
5
|
+
|
|
6
|
+
Example::
|
|
7
|
+
|
|
8
|
+
from plentymarkets._types import OrderCreatePayload
|
|
9
|
+
|
|
10
|
+
payload: OrderCreatePayload = {"typeId": 1, "plentyId": 12345}
|
|
11
|
+
order = await client.orders.create(payload)
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from typing import NotRequired, Required, TypedDict
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# ---------------------------------------------------------------------------
|
|
18
|
+
# Orders (/rest/orders)
|
|
19
|
+
# ---------------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class OrderCreatePayload(TypedDict, total=False):
|
|
23
|
+
"""Payload for :meth:`OrdersModule.create` (``POST /rest/orders``).
|
|
24
|
+
|
|
25
|
+
Keys:
|
|
26
|
+
typeId: Order type ID. **Required.**
|
|
27
|
+
1=sales order, 2=delivery order, 3=returns, 4=credit note,
|
|
28
|
+
5=warranty, 6=repair, 7=offer, 8=advance order, 9=multi-order.
|
|
29
|
+
plentyId: Client (store) ID the order belongs to. **Required.**
|
|
30
|
+
statusId: Numeric order status ID (e.g. ``3.0`` = waiting for payment,
|
|
31
|
+
``5.0`` = cleared for shipping). Decimal part is used for
|
|
32
|
+
sub-statuses.
|
|
33
|
+
ownerId: User ID of the order owner.
|
|
34
|
+
lockStatus: Lock state of the order. ``"unlocked"`` or ``"locked"``.
|
|
35
|
+
orderItems: List of order item dicts. Each item should include
|
|
36
|
+
``typeId``, ``itemVariationId``, ``quantity``, and ``amounts``.
|
|
37
|
+
properties: List of order property dicts, each with ``typeId`` and
|
|
38
|
+
``value``.
|
|
39
|
+
addressRelations: List of address relation dicts. Each entry requires
|
|
40
|
+
``typeId`` (1=billing, 2=delivery) and ``addressId``.
|
|
41
|
+
relations: List of relation dicts, each with ``referenceType``,
|
|
42
|
+
``referenceId``, and ``relation``.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
typeId: Required[int]
|
|
46
|
+
plentyId: Required[int]
|
|
47
|
+
statusId: NotRequired[float]
|
|
48
|
+
ownerId: NotRequired[int]
|
|
49
|
+
lockStatus: NotRequired[str]
|
|
50
|
+
orderItems: NotRequired[list[dict]]
|
|
51
|
+
properties: NotRequired[list[dict]]
|
|
52
|
+
addressRelations: NotRequired[list[dict]]
|
|
53
|
+
relations: NotRequired[list[dict]]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# ---------------------------------------------------------------------------
|
|
57
|
+
# Items (/rest/items)
|
|
58
|
+
# ---------------------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class ItemCreatePayload(TypedDict, total=False):
|
|
62
|
+
"""Payload for :meth:`ItemsModule.create` (``POST /rest/items``).
|
|
63
|
+
|
|
64
|
+
Keys:
|
|
65
|
+
position: Display position in item lists.
|
|
66
|
+
stockType: Stock type. 0=stocked, 1=production, 2=colli,
|
|
67
|
+
3=special order.
|
|
68
|
+
storeSpecial: Special shop display flag. 0=none, 1=special offer,
|
|
69
|
+
2=new item, 3=top item.
|
|
70
|
+
ownerId: User ID of the item owner.
|
|
71
|
+
manufacturerId: Manufacturer ID.
|
|
72
|
+
producingCountryId: Country of origin ID (ISO country ID).
|
|
73
|
+
vatId: VAT configuration ID.
|
|
74
|
+
condition: Item condition. 0=new, 1=used, 2=refurbished,
|
|
75
|
+
3=new with original packaging, 4=new with defects, 5=B-stock.
|
|
76
|
+
conditionApi: Condition value exposed via marketplace APIs
|
|
77
|
+
(0=new, 1=used, 2=not specified, 3=defective, 4=refurbished).
|
|
78
|
+
customsTariffNumber: Harmonized System (HS) customs tariff code.
|
|
79
|
+
revenueAccount: Revenue account number for accounting.
|
|
80
|
+
couponRestriction: 0=allow all, 1=forbid all, 2=allow coupons only.
|
|
81
|
+
isSubscribable: Whether the item supports subscription ordering.
|
|
82
|
+
isShippingPackage: Whether the item ships as a package unit.
|
|
83
|
+
flagOne: Item flag 1 (shop-specific integer tag).
|
|
84
|
+
flagTwo: Item flag 2 (shop-specific integer tag).
|
|
85
|
+
ageRestriction: Age restriction in years. Common values: 0, 6, 12,
|
|
86
|
+
14, 16, 18, 50.
|
|
87
|
+
itemType: Item type string. ``"Default"``, ``"Bundle"``,
|
|
88
|
+
or ``"BundleItem"``.
|
|
89
|
+
amazonFbaPlatform: Amazon FBA platform. 0=none, 1=AMAZON_EU,
|
|
90
|
+
2=AMAZON_FE, 3=AMAZON_NA.
|
|
91
|
+
isShippableByAmazon: Whether Amazon is allowed to fulfil this item.
|
|
92
|
+
amazonProductType: Amazon browse-node / product type number.
|
|
93
|
+
ebayPresetId: eBay listing preset ID.
|
|
94
|
+
ebayCategory: Primary eBay category ID.
|
|
95
|
+
ebayCategory2: Secondary eBay category ID.
|
|
96
|
+
rakutenCategoryId: Rakuten product category ID.
|
|
97
|
+
variations: List of variation dicts. Provide at least one variation
|
|
98
|
+
with ``isMain: true`` to create a usable item.
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
position: NotRequired[int]
|
|
102
|
+
stockType: NotRequired[int]
|
|
103
|
+
storeSpecial: NotRequired[int]
|
|
104
|
+
ownerId: NotRequired[int]
|
|
105
|
+
manufacturerId: NotRequired[int]
|
|
106
|
+
producingCountryId: NotRequired[int]
|
|
107
|
+
vatId: NotRequired[int]
|
|
108
|
+
condition: NotRequired[int]
|
|
109
|
+
conditionApi: NotRequired[int]
|
|
110
|
+
customsTariffNumber: NotRequired[str]
|
|
111
|
+
revenueAccount: NotRequired[int]
|
|
112
|
+
couponRestriction: NotRequired[int]
|
|
113
|
+
isSubscribable: NotRequired[bool]
|
|
114
|
+
isShippingPackage: NotRequired[bool]
|
|
115
|
+
flagOne: NotRequired[int]
|
|
116
|
+
flagTwo: NotRequired[int]
|
|
117
|
+
ageRestriction: NotRequired[int]
|
|
118
|
+
itemType: NotRequired[str]
|
|
119
|
+
amazonFbaPlatform: NotRequired[int]
|
|
120
|
+
isShippableByAmazon: NotRequired[bool]
|
|
121
|
+
amazonProductType: NotRequired[int]
|
|
122
|
+
ebayPresetId: NotRequired[int]
|
|
123
|
+
ebayCategory: NotRequired[int]
|
|
124
|
+
ebayCategory2: NotRequired[int]
|
|
125
|
+
rakutenCategoryId: NotRequired[int]
|
|
126
|
+
variations: NotRequired[list[dict]]
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class ItemUpdatePayload(TypedDict, total=False):
|
|
130
|
+
"""Payload for :meth:`ItemsModule.update` (``PUT /rest/items/{itemId}``).
|
|
131
|
+
|
|
132
|
+
All keys are optional — only the fields provided are updated.
|
|
133
|
+
|
|
134
|
+
Keys:
|
|
135
|
+
position: Display position in item lists.
|
|
136
|
+
stockType: Stock type. 0=stocked, 1=production, 2=colli,
|
|
137
|
+
3=special order.
|
|
138
|
+
storeSpecial: Special shop display flag.
|
|
139
|
+
ownerId: User ID of the item owner.
|
|
140
|
+
manufacturerId: Manufacturer ID.
|
|
141
|
+
producingCountryId: Country of origin ID.
|
|
142
|
+
vatId: VAT configuration ID.
|
|
143
|
+
condition: Item condition (0–5).
|
|
144
|
+
conditionApi: Marketplace condition value (0–4).
|
|
145
|
+
customsTariffNumber: HS / customs tariff code.
|
|
146
|
+
revenueAccount: Revenue account number.
|
|
147
|
+
couponRestriction: 0=allow all, 1=forbid all, 2=allow coupons only.
|
|
148
|
+
isSubscribable: Whether the item supports subscription ordering.
|
|
149
|
+
isShippingPackage: Whether the item ships as a package unit.
|
|
150
|
+
flagOne: Item flag 1.
|
|
151
|
+
flagTwo: Item flag 2.
|
|
152
|
+
ageRestriction: Age restriction in years.
|
|
153
|
+
itemType: ``"Default"``, ``"Bundle"``, or ``"BundleItem"``.
|
|
154
|
+
amazonFbaPlatform: 0=none, 1=AMAZON_EU, 2=AMAZON_FE, 3=AMAZON_NA.
|
|
155
|
+
isShippableByAmazon: Whether Amazon can fulfil this item.
|
|
156
|
+
amazonProductType: Amazon product type number.
|
|
157
|
+
ebayPresetId: eBay listing preset ID.
|
|
158
|
+
ebayCategory: Primary eBay category ID.
|
|
159
|
+
ebayCategory2: Secondary eBay category ID.
|
|
160
|
+
rakutenCategoryId: Rakuten product category ID.
|
|
161
|
+
"""
|
|
162
|
+
|
|
163
|
+
position: NotRequired[int]
|
|
164
|
+
stockType: NotRequired[int]
|
|
165
|
+
storeSpecial: NotRequired[int]
|
|
166
|
+
ownerId: NotRequired[int]
|
|
167
|
+
manufacturerId: NotRequired[int]
|
|
168
|
+
producingCountryId: NotRequired[int]
|
|
169
|
+
vatId: NotRequired[int]
|
|
170
|
+
condition: NotRequired[int]
|
|
171
|
+
conditionApi: NotRequired[int]
|
|
172
|
+
customsTariffNumber: NotRequired[str]
|
|
173
|
+
revenueAccount: NotRequired[int]
|
|
174
|
+
couponRestriction: NotRequired[int]
|
|
175
|
+
isSubscribable: NotRequired[bool]
|
|
176
|
+
isShippingPackage: NotRequired[bool]
|
|
177
|
+
flagOne: NotRequired[int]
|
|
178
|
+
flagTwo: NotRequired[int]
|
|
179
|
+
ageRestriction: NotRequired[int]
|
|
180
|
+
itemType: NotRequired[str]
|
|
181
|
+
amazonFbaPlatform: NotRequired[int]
|
|
182
|
+
isShippableByAmazon: NotRequired[bool]
|
|
183
|
+
amazonProductType: NotRequired[int]
|
|
184
|
+
ebayPresetId: NotRequired[int]
|
|
185
|
+
ebayCategory: NotRequired[int]
|
|
186
|
+
ebayCategory2: NotRequired[int]
|
|
187
|
+
rakutenCategoryId: NotRequired[int]
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
# ---------------------------------------------------------------------------
|
|
191
|
+
# Stock (/rest/stock/warehouses/{warehouseId}/stock/correction)
|
|
192
|
+
# ---------------------------------------------------------------------------
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
class StockCorrectionPayload(TypedDict):
|
|
196
|
+
"""Payload for :meth:`StockModule.correct`
|
|
197
|
+
(``PUT /rest/stock/warehouses/{warehouseId}/stock/correction``).
|
|
198
|
+
|
|
199
|
+
Keys:
|
|
200
|
+
variationId: Variation ID whose stock should be corrected. **Required.**
|
|
201
|
+
quantity: New absolute stock quantity after correction. **Required.**
|
|
202
|
+
Positive values add stock; the API replaces the current level.
|
|
203
|
+
reasonId: Correction reason ID. **Required.** Common values:
|
|
204
|
+
301=stock correction, 304=incoming items, 305=outgoing items,
|
|
205
|
+
309=returns.
|
|
206
|
+
warehouseLocationId: Storage location ID within the warehouse.
|
|
207
|
+
**Required.** Use ``0`` for the warehouse default location.
|
|
208
|
+
"""
|
|
209
|
+
|
|
210
|
+
variationId: int
|
|
211
|
+
quantity: float
|
|
212
|
+
reasonId: int
|
|
213
|
+
warehouseLocationId: int
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
# ---------------------------------------------------------------------------
|
|
217
|
+
# Contacts (/rest/accounts/contacts)
|
|
218
|
+
# ---------------------------------------------------------------------------
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
class ContactCreatePayload(TypedDict, total=False):
|
|
222
|
+
"""Payload for :meth:`ContactsModule.create`
|
|
223
|
+
(``POST /rest/accounts/contacts``).
|
|
224
|
+
|
|
225
|
+
Keys:
|
|
226
|
+
typeId: Contact type. 1=customer, 2=interested party, 3=sales rep,
|
|
227
|
+
4=supplier, 5=manufacturer, 6=partner.
|
|
228
|
+
referrerId: Sales referrer ID (float, links to the referrer table).
|
|
229
|
+
title: Title or form of address string (e.g. ``"Dr."``).
|
|
230
|
+
firstName: Contact first name.
|
|
231
|
+
lastName: Contact last name.
|
|
232
|
+
email: Primary e-mail address.
|
|
233
|
+
gender: Gender string. ``"male"``, ``"female"``, or ``"diverse"``.
|
|
234
|
+
lang: Two-letter ISO language code for the contact's preferred
|
|
235
|
+
language (e.g. ``"de"``, ``"en"``).
|
|
236
|
+
plentyId: Client (store) ID the contact belongs to.
|
|
237
|
+
options: List of contact option dicts. Each option requires
|
|
238
|
+
``typeId`` (1=telephone, 2=email, 3=messenger, 4=marketplace),
|
|
239
|
+
``subTypeId``, ``value``, and ``priority``.
|
|
240
|
+
"""
|
|
241
|
+
|
|
242
|
+
typeId: NotRequired[int]
|
|
243
|
+
referrerId: NotRequired[float]
|
|
244
|
+
title: NotRequired[str]
|
|
245
|
+
firstName: NotRequired[str]
|
|
246
|
+
lastName: NotRequired[str]
|
|
247
|
+
email: NotRequired[str]
|
|
248
|
+
gender: NotRequired[str]
|
|
249
|
+
lang: NotRequired[str]
|
|
250
|
+
plentyId: NotRequired[int]
|
|
251
|
+
options: NotRequired[list[dict]]
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
class ContactUpdatePayload(TypedDict, total=False):
|
|
255
|
+
"""Payload for :meth:`ContactsModule.update`
|
|
256
|
+
(``PUT /rest/accounts/contacts/{contactId}``).
|
|
257
|
+
|
|
258
|
+
All keys are optional — only the fields provided are updated.
|
|
259
|
+
|
|
260
|
+
Keys:
|
|
261
|
+
title: Title or form of address string.
|
|
262
|
+
firstName: Contact first name.
|
|
263
|
+
lastName: Contact last name.
|
|
264
|
+
email: Primary e-mail address.
|
|
265
|
+
gender: Gender string. ``"male"``, ``"female"``, or ``"diverse"``.
|
|
266
|
+
lang: Two-letter ISO language code.
|
|
267
|
+
options: List of contact option dicts (typeId, subTypeId, value,
|
|
268
|
+
priority). Replaces all existing options of the provided types.
|
|
269
|
+
"""
|
|
270
|
+
|
|
271
|
+
title: NotRequired[str]
|
|
272
|
+
firstName: NotRequired[str]
|
|
273
|
+
lastName: NotRequired[str]
|
|
274
|
+
email: NotRequired[str]
|
|
275
|
+
gender: NotRequired[str]
|
|
276
|
+
lang: NotRequired[str]
|
|
277
|
+
options: NotRequired[list[dict]]
|
plentymarkets/client.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import httpx
|
|
4
|
+
|
|
5
|
+
from ._session import _Session
|
|
6
|
+
from .modules.contacts import ContactsModule
|
|
7
|
+
from .modules.items import ItemsModule
|
|
8
|
+
from .modules.orders import OrdersModule
|
|
9
|
+
from .modules.stock import StockModule
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class PlentyClient:
|
|
13
|
+
"""Async client for the PlentyMarkets REST API.
|
|
14
|
+
|
|
15
|
+
Use as an async context manager (recommended) or call ``login()``/``aclose()``
|
|
16
|
+
manually.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
base_url: Shop base URL, e.g. ``https://your-shop.plentymarkets.com``.
|
|
20
|
+
username: PlentyMarkets back-end user e-mail.
|
|
21
|
+
password: PlentyMarkets back-end user password.
|
|
22
|
+
|
|
23
|
+
Example::
|
|
24
|
+
|
|
25
|
+
async with PlentyClient(
|
|
26
|
+
base_url="https://your-shop.plentymarkets.com",
|
|
27
|
+
username="user@example.com",
|
|
28
|
+
password="secret",
|
|
29
|
+
) as client:
|
|
30
|
+
order = await client.orders.get(42)
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(self, base_url: str, username: str, password: str) -> None:
|
|
34
|
+
self._httpx_client = httpx.AsyncClient()
|
|
35
|
+
self._session = _Session(base_url, username, password, self._httpx_client)
|
|
36
|
+
self._orders: OrdersModule | None = None
|
|
37
|
+
self._items: ItemsModule | None = None
|
|
38
|
+
self._stock: StockModule | None = None
|
|
39
|
+
self._contacts: ContactsModule | None = None
|
|
40
|
+
|
|
41
|
+
def __repr__(self) -> str:
|
|
42
|
+
authenticated = self._session._access_token is not None
|
|
43
|
+
return (
|
|
44
|
+
f"PlentyClient(base_url={self._session._base_url!r}, "
|
|
45
|
+
f"authenticated={authenticated})"
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
async def __aenter__(self) -> PlentyClient:
|
|
49
|
+
await self.login()
|
|
50
|
+
return self
|
|
51
|
+
|
|
52
|
+
async def __aexit__(self, *_: object) -> None:
|
|
53
|
+
try:
|
|
54
|
+
await self.logout()
|
|
55
|
+
finally:
|
|
56
|
+
await self.aclose()
|
|
57
|
+
|
|
58
|
+
async def login(self) -> None:
|
|
59
|
+
"""Authenticate and acquire an access token."""
|
|
60
|
+
await self._session.login()
|
|
61
|
+
|
|
62
|
+
async def logout(self) -> None:
|
|
63
|
+
"""Invalidate the current token on the server and clear local state."""
|
|
64
|
+
await self._session.logout()
|
|
65
|
+
|
|
66
|
+
async def aclose(self) -> None:
|
|
67
|
+
"""Close the underlying HTTP connection pool."""
|
|
68
|
+
await self._httpx_client.aclose()
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def orders(self) -> OrdersModule:
|
|
72
|
+
"""Access the Orders module (``/rest/orders``)."""
|
|
73
|
+
if self._orders is None:
|
|
74
|
+
self._orders = OrdersModule(self._session)
|
|
75
|
+
return self._orders
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def items(self) -> ItemsModule:
|
|
79
|
+
"""Access the Items module (``/rest/items``)."""
|
|
80
|
+
if self._items is None:
|
|
81
|
+
self._items = ItemsModule(self._session)
|
|
82
|
+
return self._items
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def stock(self) -> StockModule:
|
|
86
|
+
"""Access the Stock module (``/rest/stock``)."""
|
|
87
|
+
if self._stock is None:
|
|
88
|
+
self._stock = StockModule(self._session)
|
|
89
|
+
return self._stock
|
|
90
|
+
|
|
91
|
+
@property
|
|
92
|
+
def contacts(self) -> ContactsModule:
|
|
93
|
+
"""Access the Contacts module (``/rest/accounts/contacts``)."""
|
|
94
|
+
if self._contacts is None:
|
|
95
|
+
self._contacts = ContactsModule(self._session)
|
|
96
|
+
return self._contacts
|
|
File without changes
|