shoply 0.1.0__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.
- shoply/__init__.py +20 -0
- shoply/_calls.py +42 -0
- shoply/_client.py +114 -0
- shoply/_customer.py +61 -0
- shoply/_errors.py +32 -0
- shoply/_files.py +26 -0
- shoply/_generated/__init__.py +1 -0
- shoply/_generated/resources.py +8413 -0
- shoply/_generated/routes.py +295 -0
- shoply/_generated/types.py +7982 -0
- shoply/_page.py +42 -0
- shoply/_transport.py +199 -0
- shoply/_version.py +1 -0
- shoply/_webhooks.py +70 -0
- shoply/py.typed +0 -0
- shoply-0.1.0.dist-info/METADATA +101 -0
- shoply-0.1.0.dist-info/RECORD +19 -0
- shoply-0.1.0.dist-info/WHEEL +4 -0
- shoply-0.1.0.dist-info/licenses/LICENSE +21 -0
shoply/__init__.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Shoply's API from your own server: every route as a method, safe retries, pages, and checking
|
|
2
|
+
webhooks. Docs: https://docs.shoply.gg/api-reference/python
|
|
3
|
+
|
|
4
|
+
import shoply
|
|
5
|
+
|
|
6
|
+
client = shoply.Client() # SHOPLY_API_KEY from the environment
|
|
7
|
+
order = client.orders.show("8f2c...")
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from . import _webhooks as webhooks
|
|
11
|
+
from ._client import Client
|
|
12
|
+
from ._customer import Customer
|
|
13
|
+
from ._errors import ShoplyError
|
|
14
|
+
from ._files import File
|
|
15
|
+
from ._generated.routes import EVENTS
|
|
16
|
+
from ._generated.types import WebhookEvent, WebhookEventName
|
|
17
|
+
from ._page import Page
|
|
18
|
+
from ._version import VERSION
|
|
19
|
+
|
|
20
|
+
__all__ = ["Client", "Customer", "ShoplyError", "File", "Page", "webhooks", "WebhookEvent", "WebhookEventName", "EVENTS", "VERSION"]
|
shoply/_calls.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""What a call of a route is made of: its address, and where its fields go."""
|
|
2
|
+
|
|
3
|
+
import urllib.parse
|
|
4
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
5
|
+
|
|
6
|
+
from ._generated.routes import ROUTES
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def prepare(id: str, path: List[str], fields: Dict[str, Any]) -> Tuple[Dict[str, Any], Dict[str, str], Optional[Dict[str, Any]], Any, Optional[Tuple[str, Any, Optional[str], Optional[str]]]]:
|
|
10
|
+
"""The route, the address's IDs by name, and the fields: as the query, the JSON body, or a file."""
|
|
11
|
+
route = ROUTES[id]
|
|
12
|
+
values: Dict[str, str] = {}
|
|
13
|
+
|
|
14
|
+
for index, name in enumerate(route["params"]):
|
|
15
|
+
value = path[index] if index < len(path) else ""
|
|
16
|
+
if not isinstance(value, str) or value == "":
|
|
17
|
+
raise ValueError(f"{id} needs {name}.")
|
|
18
|
+
values[name] = value
|
|
19
|
+
|
|
20
|
+
given = {name: value for name, value in fields.items() if value is not None}
|
|
21
|
+
query: Optional[Dict[str, Any]] = None
|
|
22
|
+
body: Any = None
|
|
23
|
+
form = None
|
|
24
|
+
|
|
25
|
+
if route["upload"]:
|
|
26
|
+
form = (route["upload"], given.get("file"), given.get("filename"), given.get("content_type"))
|
|
27
|
+
# What the route takes beside the file (a dispute's file says its `kind`) is in the address.
|
|
28
|
+
query = {name: value for name, value in given.items() if name in route["query"]}
|
|
29
|
+
elif route["body"]:
|
|
30
|
+
query = {name: value for name, value in given.items() if name in route["query"]}
|
|
31
|
+
body = {name: value for name, value in given.items() if name not in route["query"]}
|
|
32
|
+
else:
|
|
33
|
+
query = given
|
|
34
|
+
|
|
35
|
+
return route, values, query, body, form
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def fill(template: str, values: Dict[str, str]) -> str:
|
|
39
|
+
"""The route's address with its IDs in it, each escaped."""
|
|
40
|
+
for name, value in values.items():
|
|
41
|
+
template = template.replace("{" + name + "}", urllib.parse.quote(value, safe=""))
|
|
42
|
+
return template
|
shoply/_client.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from typing import Any, Dict, List, Optional, Union
|
|
3
|
+
|
|
4
|
+
from ._calls import fill, prepare
|
|
5
|
+
from ._errors import ShoplyError
|
|
6
|
+
from ._files import File
|
|
7
|
+
from ._generated.resources import ClientResources
|
|
8
|
+
from ._page import Page
|
|
9
|
+
from ._transport import Transport
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Client(ClientResources):
|
|
13
|
+
"""Shoply's API from your own server, with a store's secret API key. Every route of the API
|
|
14
|
+
reference is a method named by its id: `orders.show` is `client.orders.show(order_id)`.
|
|
15
|
+
|
|
16
|
+
client = shoply.Client(api_key=os.environ["SHOPLY_API_KEY"])
|
|
17
|
+
order = client.orders.show("8f2c...")
|
|
18
|
+
|
|
19
|
+
A call takes the IDs in the route's address, in order, then its fields as keyword
|
|
20
|
+
arguments, then options (`idempotency_key`, `timeout`, `max_retries`, `headers`). It answers
|
|
21
|
+
the route's `data`, as dicts; a list that is paged answers a Page, and a file a File.
|
|
22
|
+
|
|
23
|
+
Docs: https://docs.shoply.gg/api-reference/python
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
api_key: Optional[str] = None,
|
|
29
|
+
*,
|
|
30
|
+
store_id: Optional[str] = None,
|
|
31
|
+
base_url: Optional[str] = None,
|
|
32
|
+
timeout: float = 60.0,
|
|
33
|
+
max_retries: int = 2,
|
|
34
|
+
) -> None:
|
|
35
|
+
"""
|
|
36
|
+
api_key: `shoply_sk_...`. Left out, SHOPLY_API_KEY from the environment.
|
|
37
|
+
store_id: the store's ID. Left out, it is asked of the key the first time it is needed.
|
|
38
|
+
timeout: how long one try waits for an answer, in seconds. 60.
|
|
39
|
+
max_retries: how many more tries a request that is safe to repeat is given. 2.
|
|
40
|
+
"""
|
|
41
|
+
key = api_key if api_key is not None else os.environ.get("SHOPLY_API_KEY")
|
|
42
|
+
|
|
43
|
+
if not isinstance(key, str) or not key.startswith("shoply_sk_"):
|
|
44
|
+
raise ShoplyError("Pass the store's secret API key as `api_key` (shoply_sk_...), or set SHOPLY_API_KEY.", code="api_key_required")
|
|
45
|
+
|
|
46
|
+
self._store_id = store_id
|
|
47
|
+
self._transport = Transport(base_url, timeout, max_retries, lambda: key)
|
|
48
|
+
self._resources()
|
|
49
|
+
|
|
50
|
+
def store_id(self) -> str:
|
|
51
|
+
"""The store the key belongs to, asked once and remembered."""
|
|
52
|
+
if self._store_id is None:
|
|
53
|
+
answer = self._transport.send("GET", "/key")
|
|
54
|
+
assert isinstance(answer, dict)
|
|
55
|
+
self._store_id = str(answer["data"]["store"]["id"])
|
|
56
|
+
return self._store_id
|
|
57
|
+
|
|
58
|
+
def request(
|
|
59
|
+
self,
|
|
60
|
+
method: str,
|
|
61
|
+
path: str,
|
|
62
|
+
*,
|
|
63
|
+
query: Optional[Dict[str, Any]] = None,
|
|
64
|
+
body: Any = None,
|
|
65
|
+
idempotency_key: Optional[str] = None,
|
|
66
|
+
timeout: Optional[float] = None,
|
|
67
|
+
max_retries: Optional[int] = None,
|
|
68
|
+
headers: Optional[Dict[str, str]] = None,
|
|
69
|
+
) -> Dict[str, Any]:
|
|
70
|
+
"""Any route, by its method and its address after /v1, for one newer than this package:
|
|
71
|
+
`{storeId}` in the path is filled in. Answers the whole envelope: `data`, and `meta`,
|
|
72
|
+
`pages` and `total` where the route has them."""
|
|
73
|
+
method = method.upper()
|
|
74
|
+
if "{storeId}" in path:
|
|
75
|
+
path = fill(path, {"storeId": self.store_id()})
|
|
76
|
+
answer = self._transport.send(
|
|
77
|
+
method, path, query=query, body=body, idempotent=method != "GET",
|
|
78
|
+
options={"idempotency_key": idempotency_key, "timeout": timeout, "max_retries": max_retries, "headers": headers},
|
|
79
|
+
)
|
|
80
|
+
assert isinstance(answer, dict)
|
|
81
|
+
return answer
|
|
82
|
+
|
|
83
|
+
def _call(self, id: str, path: List[str], fields: Dict[str, Any], options: Dict[str, Any]) -> Any:
|
|
84
|
+
route, values, query, body, form = prepare(id, path, fields)
|
|
85
|
+
|
|
86
|
+
def fetch(page: int) -> Union[Dict[str, Any], File]:
|
|
87
|
+
filled = dict(values)
|
|
88
|
+
if "{storeId}" in route["path"]:
|
|
89
|
+
filled["storeId"] = self.store_id()
|
|
90
|
+
asked = dict(query or {}, page=page) if route["paged"] else query
|
|
91
|
+
return self._transport.send(
|
|
92
|
+
route["method"], fill(route["path"], filled), query=asked, body=body, form=form,
|
|
93
|
+
idempotent=route["idempotent"], file=route["file"], options=options,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
if route["paged"]:
|
|
97
|
+
given = fields.get("page")
|
|
98
|
+
return _page(fetch, given if isinstance(given, int) and given > 0 else 1)
|
|
99
|
+
|
|
100
|
+
answer = fetch(1)
|
|
101
|
+
return answer if isinstance(answer, File) else answer.get("data")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _page(fetch: Any, number: int) -> Page[Any]:
|
|
105
|
+
answer = fetch(number)
|
|
106
|
+
data = answer.get("data")
|
|
107
|
+
return Page(
|
|
108
|
+
list(data) if isinstance(data, list) else [],
|
|
109
|
+
number,
|
|
110
|
+
int(answer["pages"]) if isinstance(answer.get("pages"), int) else None,
|
|
111
|
+
int(answer["total"]) if isinstance(answer.get("total"), int) else None,
|
|
112
|
+
answer.get("meta") if isinstance(answer.get("meta"), dict) else None,
|
|
113
|
+
lambda next: _page(fetch, next),
|
|
114
|
+
)
|
shoply/_customer.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
from typing import Any, Dict, List, Optional
|
|
2
|
+
|
|
3
|
+
from ._calls import fill, prepare
|
|
4
|
+
from ._errors import ShoplyError
|
|
5
|
+
from ._generated.resources import CustomerResources
|
|
6
|
+
from ._transport import Transport
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Customer(CustomerResources):
|
|
10
|
+
"""The Customer API: a buyer signed in on your own site or app with a code emailed to them,
|
|
11
|
+
reading their own orders, keys, subscriptions and affiliate page. It holds the buyer's token
|
|
12
|
+
and no key.
|
|
13
|
+
|
|
14
|
+
buyer = shoply.Customer(store_id="YOUR_STORE_ID")
|
|
15
|
+
buyer.login(email=email)
|
|
16
|
+
buyer.verify(email=email, code=code) # keeps the token
|
|
17
|
+
me = buyer.show()
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def __init__(
|
|
21
|
+
self,
|
|
22
|
+
store_id: str,
|
|
23
|
+
*,
|
|
24
|
+
token: Optional[str] = None,
|
|
25
|
+
base_url: Optional[str] = None,
|
|
26
|
+
timeout: float = 60.0,
|
|
27
|
+
max_retries: int = 2,
|
|
28
|
+
) -> None:
|
|
29
|
+
"""
|
|
30
|
+
store_id: the store's ID, from Settings, General. It is not a secret.
|
|
31
|
+
token: a token from an earlier sign-in (`shoply_ct_...`), if you kept one.
|
|
32
|
+
"""
|
|
33
|
+
if not isinstance(store_id, str) or store_id == "":
|
|
34
|
+
raise ShoplyError("Pass the store's ID as `store_id`. It is in Settings, General, in the store's dashboard.", code="store_id_required")
|
|
35
|
+
|
|
36
|
+
self.store_id = store_id
|
|
37
|
+
self.token = token
|
|
38
|
+
self._transport = Transport(base_url, timeout, max_retries, lambda: self.token)
|
|
39
|
+
self._resources()
|
|
40
|
+
|
|
41
|
+
def _call(self, id: str, path: List[str], fields: Dict[str, Any], options: Dict[str, Any]) -> Any:
|
|
42
|
+
route, values, query, body, _ = prepare(id, path, fields)
|
|
43
|
+
|
|
44
|
+
if route["signed_in"] and not self.token:
|
|
45
|
+
raise ShoplyError("Sign the buyer in first: login, then verify with the code they were emailed.", code="customer_token_required")
|
|
46
|
+
|
|
47
|
+
answer = self._transport.send(
|
|
48
|
+
route["method"], fill(route["path"], dict(values, storeId=self.store_id)),
|
|
49
|
+
query=query, body=body, idempotent=False, options=options,
|
|
50
|
+
)
|
|
51
|
+
assert isinstance(answer, dict)
|
|
52
|
+
data = answer.get("data")
|
|
53
|
+
|
|
54
|
+
# Signing in hands out the token; signing out ends it.
|
|
55
|
+
if id == "customer.verify" and isinstance(data, dict) and isinstance(data.get("token"), str):
|
|
56
|
+
self.token = data["token"]
|
|
57
|
+
|
|
58
|
+
if id in ("customer.logout", "customer.logoutEverywhere"):
|
|
59
|
+
self.token = None
|
|
60
|
+
|
|
61
|
+
return data
|
shoply/_errors.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
from typing import Any, Dict, Optional
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class ShoplyError(Exception):
|
|
5
|
+
"""A request Shoply refused, or one that never got an answer.
|
|
6
|
+
|
|
7
|
+
`status` is the HTTP status (0 when nothing answered), `code` the word that does not change
|
|
8
|
+
(`not_found`, `missing_scope`, `validation_failed`, `rate_limited`... and `connection_error`
|
|
9
|
+
or `timeout` when nothing answered), `errors` what is wrong with each field for
|
|
10
|
+
`validation_failed`, `request_id` what to quote to Shoply about it, `retry_after` the seconds
|
|
11
|
+
to wait when told to slow down, and `body` the whole answer.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
def __init__(
|
|
15
|
+
self,
|
|
16
|
+
message: str,
|
|
17
|
+
*,
|
|
18
|
+
status: int = 0,
|
|
19
|
+
code: Optional[str] = None,
|
|
20
|
+
errors: Optional[Dict[str, str]] = None,
|
|
21
|
+
request_id: Optional[str] = None,
|
|
22
|
+
retry_after: Optional[int] = None,
|
|
23
|
+
body: Any = None,
|
|
24
|
+
) -> None:
|
|
25
|
+
super().__init__(message)
|
|
26
|
+
self.message = message
|
|
27
|
+
self.status = status
|
|
28
|
+
self.code = code
|
|
29
|
+
self.errors = errors
|
|
30
|
+
self.request_id = request_id
|
|
31
|
+
self.retry_after = retry_after
|
|
32
|
+
self.body = body
|
shoply/_files.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from typing import Dict, Optional, Union
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class File:
|
|
6
|
+
"""A route's answer that is a file, a CSV or a PDF, as it came: `body` its bytes, `headers` by
|
|
7
|
+
lowercase name."""
|
|
8
|
+
|
|
9
|
+
def __init__(self, body: bytes, headers: Dict[str, str]) -> None:
|
|
10
|
+
self.body = body
|
|
11
|
+
self.headers = headers
|
|
12
|
+
|
|
13
|
+
@property
|
|
14
|
+
def content_type(self) -> Optional[str]:
|
|
15
|
+
"""Its type, "text/csv"."""
|
|
16
|
+
return self.headers.get("content-type")
|
|
17
|
+
|
|
18
|
+
def text(self, encoding: str = "utf-8-sig") -> str:
|
|
19
|
+
"""Its body as text. A CSV from Shoply starts with a byte order mark, for spreadsheets,
|
|
20
|
+
which this leaves out."""
|
|
21
|
+
return self.body.decode(encoding)
|
|
22
|
+
|
|
23
|
+
def save(self, path: Union[str, "os.PathLike[str]"]) -> None:
|
|
24
|
+
"""Written to a file."""
|
|
25
|
+
with open(path, "wb") as handle:
|
|
26
|
+
handle.write(self.body)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# Written by scripts/generate.py from the API reference. Do not edit by hand.
|