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.
@@ -0,0 +1,6 @@
1
+ __pycache__/
2
+ *.egg-info/
3
+ /build/
4
+ /dist/
5
+ /.mypy_cache/
6
+ /.venv/
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.