shoply 0.1.0__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.
- shoply-0.1.0/.gitignore +6 -0
- shoply-0.1.0/LICENSE +21 -0
- shoply-0.1.0/PKG-INFO +101 -0
- shoply-0.1.0/README.md +78 -0
- shoply-0.1.0/pyproject.toml +37 -0
- shoply-0.1.0/src/shoply/__init__.py +20 -0
- shoply-0.1.0/src/shoply/_calls.py +42 -0
- shoply-0.1.0/src/shoply/_client.py +114 -0
- shoply-0.1.0/src/shoply/_customer.py +61 -0
- shoply-0.1.0/src/shoply/_errors.py +32 -0
- shoply-0.1.0/src/shoply/_files.py +26 -0
- shoply-0.1.0/src/shoply/_generated/__init__.py +1 -0
- shoply-0.1.0/src/shoply/_generated/resources.py +8413 -0
- shoply-0.1.0/src/shoply/_generated/routes.py +295 -0
- shoply-0.1.0/src/shoply/_generated/types.py +7982 -0
- shoply-0.1.0/src/shoply/_page.py +42 -0
- shoply-0.1.0/src/shoply/_transport.py +199 -0
- shoply-0.1.0/src/shoply/_version.py +1 -0
- shoply-0.1.0/src/shoply/_webhooks.py +70 -0
- shoply-0.1.0/src/shoply/py.typed +0 -0
- shoply-0.1.0/tests/standin.py +157 -0
- shoply-0.1.0/tests/test_client.py +390 -0
shoply-0.1.0/.gitignore
ADDED
shoply-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Shoply
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
shoply-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: shoply
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Shoply's API from your own server: every route as a method, safe retries, pages, and checking webhooks.
|
|
5
|
+
Project-URL: Documentation, https://docs.shoply.gg/api-reference/python
|
|
6
|
+
Project-URL: Source, https://github.com/shoplygg/shoply-python
|
|
7
|
+
Project-URL: Issues, https://github.com/shoplygg/shoply-python/issues
|
|
8
|
+
Project-URL: Changelog, https://docs.shoply.gg/changelog
|
|
9
|
+
Author: Shoply
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: api,digital-goods,ecommerce,sdk,shoply,webhooks
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
18
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
19
|
+
Classifier: Topic :: Office/Business :: Financial :: Point-Of-Sale
|
|
20
|
+
Classifier: Typing :: Typed
|
|
21
|
+
Requires-Python: >=3.9
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# shoply
|
|
25
|
+
|
|
26
|
+
Shoply's API from your own server. Every route of the [API reference](https://docs.shoply.gg/api-reference/introduction) is a method, with types your editor and mypy read, safe retries, lists a page at a time or all at once, and checking that a webhook came from Shoply.
|
|
27
|
+
|
|
28
|
+
```sh
|
|
29
|
+
pip install shoply
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Python 3.9 or later. No dependencies.
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
import shoply
|
|
36
|
+
|
|
37
|
+
client = shoply.Client() # SHOPLY_API_KEY from the environment
|
|
38
|
+
|
|
39
|
+
order = client.orders.show("ORDER_ID")
|
|
40
|
+
|
|
41
|
+
for customer in client.customers.list(search="gmail.com"):
|
|
42
|
+
print(customer["emailAddress"])
|
|
43
|
+
|
|
44
|
+
coupon = client.coupons.create(code="SPRING10", value=10)
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Make the key under **Developers**, **API Keys** in the dashboard, with the permissions your code needs. Keep it on your server.
|
|
48
|
+
|
|
49
|
+
A route's method is its id: **Get an order** is `orders.show`, so `client.orders.show(order_id)`. The address's IDs come first, in order, then the fields as keyword arguments, by the names the reference gives them, then options. A field whose name is a Python keyword takes an underscore: `from_`.
|
|
50
|
+
|
|
51
|
+
## Errors
|
|
52
|
+
|
|
53
|
+
A refusal is a `shoply.ShoplyError`, with `status`, `code` (`not_found`, `missing_scope`, `validation_failed`...), `errors` for each field that is wrong, and `request_id`.
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
try:
|
|
57
|
+
client.coupons.create(code="SPRING10", value=10)
|
|
58
|
+
except shoply.ShoplyError as error:
|
|
59
|
+
if error.code == "validation_failed":
|
|
60
|
+
print(error.errors)
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
A change is sent with an `Idempotency-Key`, the same on every try, so a try whose answer was lost is never carried out twice. Pass `idempotency_key=...` to use your own.
|
|
64
|
+
|
|
65
|
+
## Webhooks
|
|
66
|
+
|
|
67
|
+
```python
|
|
68
|
+
import os
|
|
69
|
+
|
|
70
|
+
import shoply
|
|
71
|
+
from flask import Flask, request
|
|
72
|
+
|
|
73
|
+
app = Flask(__name__)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@app.post("/webhooks/shoply")
|
|
77
|
+
def shoply_webhook() -> tuple[str, int]:
|
|
78
|
+
try:
|
|
79
|
+
event = shoply.webhooks.verify(request.get_data(), request.headers, os.environ["SHOPLY_WEBHOOK_SECRET"])
|
|
80
|
+
except shoply.ShoplyError:
|
|
81
|
+
return "", 401
|
|
82
|
+
|
|
83
|
+
if event["event"] == "order.paid":
|
|
84
|
+
... # event["data"] is the order
|
|
85
|
+
|
|
86
|
+
return "", 200
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## The Customer API
|
|
90
|
+
|
|
91
|
+
`shoply.Customer` signs a buyer in with a code emailed to them, and reads their own orders, keys and subscriptions. It holds the buyer's token and no key.
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
buyer = shoply.Customer("YOUR_STORE_ID")
|
|
95
|
+
|
|
96
|
+
buyer.login(email=email)
|
|
97
|
+
buyer.verify(email=email, code=code)
|
|
98
|
+
me = buyer.show()
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
The whole guide: https://docs.shoply.gg/api-reference/python
|
shoply-0.1.0/README.md
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# shoply
|
|
2
|
+
|
|
3
|
+
Shoply's API from your own server. Every route of the [API reference](https://docs.shoply.gg/api-reference/introduction) is a method, with types your editor and mypy read, safe retries, lists a page at a time or all at once, and checking that a webhook came from Shoply.
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
pip install shoply
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
Python 3.9 or later. No dependencies.
|
|
10
|
+
|
|
11
|
+
```python
|
|
12
|
+
import shoply
|
|
13
|
+
|
|
14
|
+
client = shoply.Client() # SHOPLY_API_KEY from the environment
|
|
15
|
+
|
|
16
|
+
order = client.orders.show("ORDER_ID")
|
|
17
|
+
|
|
18
|
+
for customer in client.customers.list(search="gmail.com"):
|
|
19
|
+
print(customer["emailAddress"])
|
|
20
|
+
|
|
21
|
+
coupon = client.coupons.create(code="SPRING10", value=10)
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Make the key under **Developers**, **API Keys** in the dashboard, with the permissions your code needs. Keep it on your server.
|
|
25
|
+
|
|
26
|
+
A route's method is its id: **Get an order** is `orders.show`, so `client.orders.show(order_id)`. The address's IDs come first, in order, then the fields as keyword arguments, by the names the reference gives them, then options. A field whose name is a Python keyword takes an underscore: `from_`.
|
|
27
|
+
|
|
28
|
+
## Errors
|
|
29
|
+
|
|
30
|
+
A refusal is a `shoply.ShoplyError`, with `status`, `code` (`not_found`, `missing_scope`, `validation_failed`...), `errors` for each field that is wrong, and `request_id`.
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
try:
|
|
34
|
+
client.coupons.create(code="SPRING10", value=10)
|
|
35
|
+
except shoply.ShoplyError as error:
|
|
36
|
+
if error.code == "validation_failed":
|
|
37
|
+
print(error.errors)
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
A change is sent with an `Idempotency-Key`, the same on every try, so a try whose answer was lost is never carried out twice. Pass `idempotency_key=...` to use your own.
|
|
41
|
+
|
|
42
|
+
## Webhooks
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
import os
|
|
46
|
+
|
|
47
|
+
import shoply
|
|
48
|
+
from flask import Flask, request
|
|
49
|
+
|
|
50
|
+
app = Flask(__name__)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@app.post("/webhooks/shoply")
|
|
54
|
+
def shoply_webhook() -> tuple[str, int]:
|
|
55
|
+
try:
|
|
56
|
+
event = shoply.webhooks.verify(request.get_data(), request.headers, os.environ["SHOPLY_WEBHOOK_SECRET"])
|
|
57
|
+
except shoply.ShoplyError:
|
|
58
|
+
return "", 401
|
|
59
|
+
|
|
60
|
+
if event["event"] == "order.paid":
|
|
61
|
+
... # event["data"] is the order
|
|
62
|
+
|
|
63
|
+
return "", 200
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## The Customer API
|
|
67
|
+
|
|
68
|
+
`shoply.Customer` signs a buyer in with a code emailed to them, and reads their own orders, keys and subscriptions. It holds the buyer's token and no key.
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
buyer = shoply.Customer("YOUR_STORE_ID")
|
|
72
|
+
|
|
73
|
+
buyer.login(email=email)
|
|
74
|
+
buyer.verify(email=email, code=code)
|
|
75
|
+
me = buyer.show()
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
The whole guide: https://docs.shoply.gg/api-reference/python
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.27"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "shoply"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Shoply's API from your own server: every route as a method, safe retries, pages, and checking webhooks."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
requires-python = ">=3.9"
|
|
13
|
+
authors = [{ name = "Shoply" }]
|
|
14
|
+
keywords = ["shoply", "api", "sdk", "ecommerce", "webhooks", "digital-goods"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 4 - Beta",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"Operating System :: OS Independent",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
21
|
+
"Topic :: Internet :: WWW/HTTP",
|
|
22
|
+
"Topic :: Office/Business :: Financial :: Point-Of-Sale",
|
|
23
|
+
"Typing :: Typed",
|
|
24
|
+
]
|
|
25
|
+
dependencies = []
|
|
26
|
+
|
|
27
|
+
[project.urls]
|
|
28
|
+
Documentation = "https://docs.shoply.gg/api-reference/python"
|
|
29
|
+
Source = "https://github.com/shoplygg/shoply-python"
|
|
30
|
+
Issues = "https://github.com/shoplygg/shoply-python/issues"
|
|
31
|
+
Changelog = "https://docs.shoply.gg/changelog"
|
|
32
|
+
|
|
33
|
+
[tool.hatch.build.targets.wheel]
|
|
34
|
+
packages = ["src/shoply"]
|
|
35
|
+
|
|
36
|
+
[tool.hatch.build.targets.sdist]
|
|
37
|
+
include = ["src/shoply", "tests", "README.md", "LICENSE", "pyproject.toml"]
|
|
@@ -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"]
|
|
@@ -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
|
|
@@ -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
|
+
)
|
|
@@ -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
|
|
@@ -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
|
|
@@ -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.
|