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 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.