softsolz 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.
- softsolz-0.1.0/.gitignore +9 -0
- softsolz-0.1.0/PKG-INFO +130 -0
- softsolz-0.1.0/README.md +105 -0
- softsolz-0.1.0/pyproject.toml +39 -0
- softsolz-0.1.0/src/softsolz/__init__.py +35 -0
- softsolz-0.1.0/src/softsolz/_client.py +60 -0
- softsolz-0.1.0/src/softsolz/_http.py +153 -0
- softsolz-0.1.0/src/softsolz/_version.py +1 -0
- softsolz-0.1.0/src/softsolz/errors.py +91 -0
- softsolz-0.1.0/src/softsolz/pagination.py +39 -0
- softsolz-0.1.0/src/softsolz/py.typed +0 -0
- softsolz-0.1.0/src/softsolz/resources/__init__.py +37 -0
- softsolz-0.1.0/src/softsolz/resources/blogs.py +50 -0
- softsolz-0.1.0/src/softsolz/resources/customer_auth.py +69 -0
- softsolz-0.1.0/src/softsolz/resources/forms.py +45 -0
- softsolz-0.1.0/src/softsolz/resources/invoicing.py +75 -0
- softsolz-0.1.0/src/softsolz/resources/payments.py +27 -0
- softsolz-0.1.0/src/softsolz/resources/smart_chat.py +87 -0
- softsolz-0.1.0/src/softsolz/resources/social.py +32 -0
- softsolz-0.1.0/src/softsolz/resources/workflows.py +45 -0
- softsolz-0.1.0/src/softsolz/webhooks.py +75 -0
- softsolz-0.1.0/tests/test_http.py +172 -0
- softsolz-0.1.0/tests/test_webhooks.py +68 -0
softsolz-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: softsolz
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Official Python SDK for the SoftSolz platform API.
|
|
5
|
+
Project-URL: Homepage, https://developer.softsolz.uk
|
|
6
|
+
Project-URL: Documentation, https://developer.softsolz.uk
|
|
7
|
+
Project-URL: Repository, https://github.com/soft-solz/sdk
|
|
8
|
+
Author: SoftSolz
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
Keywords: api,forms,invoicing,payments,sdk,softsolz,webhooks
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Requires-Python: >=3.9
|
|
21
|
+
Requires-Dist: httpx<1,>=0.26
|
|
22
|
+
Provides-Extra: dev
|
|
23
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# softsolz
|
|
27
|
+
|
|
28
|
+
Official Python SDK for the [SoftSolz platform API](https://developer.softsolz.uk).
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
pip install softsolz
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Requires Python 3.9 or later. Fully type-annotated (`py.typed`).
|
|
35
|
+
|
|
36
|
+
## Usage
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
import os
|
|
40
|
+
from softsolz import SoftSolz
|
|
41
|
+
|
|
42
|
+
client = SoftSolz(api_key=os.environ["SOFTSOLZ_API_KEY"])
|
|
43
|
+
|
|
44
|
+
me = client.whoami()
|
|
45
|
+
|
|
46
|
+
form = client.forms.create_form({"name": "Contact Us", "status": "published"})
|
|
47
|
+
client.forms.submit_form(form["slug"], {"data": {"full_name": "Jane Doe", "email": "jane@example.com"}})
|
|
48
|
+
|
|
49
|
+
invoice = client.invoicing.create_invoice({
|
|
50
|
+
"customer_id": 42,
|
|
51
|
+
"lines": [{"description": "Design work", "quantity": 1, "unit_price_cents": 50000}],
|
|
52
|
+
})
|
|
53
|
+
client.invoicing.send_invoice(invoice["id"])
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Use an `sk_test_*` key to run against your sandbox workspace; swap to `sk_live_*` in production. No other configuration changes.
|
|
57
|
+
|
|
58
|
+
### Services
|
|
59
|
+
|
|
60
|
+
`client.blogs`, `client.customer_auth`, `client.forms`, `client.invoicing`, `client.payments`, `client.smart_chat`, `client.social`, `client.workflows` - one method per API operation, generated from the platform's service manifests.
|
|
61
|
+
|
|
62
|
+
### Pagination
|
|
63
|
+
|
|
64
|
+
List methods return a `Page` you can iterate; extra pages are fetched automatically:
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
for invoice in client.invoicing.list_invoices({"status": "overdue"}):
|
|
68
|
+
print(invoice["invoice_number"])
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Or page manually with `page.data`, `page.has_more`, and `page.next_page()`.
|
|
72
|
+
|
|
73
|
+
### Errors
|
|
74
|
+
|
|
75
|
+
All API failures raise a typed subclass of `SoftSolzError` carrying `status`, `code`, `message`, `details`, and `request_id`:
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
from softsolz import NotFoundError
|
|
79
|
+
|
|
80
|
+
try:
|
|
81
|
+
client.forms.get_form(999)
|
|
82
|
+
except NotFoundError as err:
|
|
83
|
+
print(err.code, err.request_id)
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Classes: `InvalidRequestError` (400/422), `AuthenticationError` (401), `PaymentRequiredError` (402), `PermissionDeniedError` (403), `NotFoundError` (404), `ConflictError` (409), `RateLimitError` (429, with `retry_after_seconds`), `APIConnectionError` (network).
|
|
87
|
+
|
|
88
|
+
### Retries and idempotency
|
|
89
|
+
|
|
90
|
+
429 and 5xx responses are retried automatically with exponential backoff (default 2 retries), honouring `Retry-After`. Every mutating request carries an auto-generated `Idempotency-Key`, so retries are always safe. Pass your own to control replays:
|
|
91
|
+
|
|
92
|
+
```python
|
|
93
|
+
client.invoicing.create_invoice(body, idempotency_key="order-1234")
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Configure per client: `SoftSolz(api_key=..., max_retries=3, timeout=60.0)`.
|
|
97
|
+
|
|
98
|
+
### Webhooks
|
|
99
|
+
|
|
100
|
+
Verify the `Softsolz-Signature` header on incoming webhooks using the raw request body:
|
|
101
|
+
|
|
102
|
+
```python
|
|
103
|
+
from softsolz import webhooks
|
|
104
|
+
|
|
105
|
+
webhooks.assert_valid(
|
|
106
|
+
raw_body=request.get_data(),
|
|
107
|
+
header_value=request.headers.get("Softsolz-Signature"),
|
|
108
|
+
secret=os.environ["SOFTSOLZ_WEBHOOK_SECRET"],
|
|
109
|
+
)
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
`webhooks.verify(...)` returns `{"valid": False, "reason": ...}` if you prefer not to raise.
|
|
113
|
+
|
|
114
|
+
### Escape hatch
|
|
115
|
+
|
|
116
|
+
Call any endpoint directly while keeping auth, retries, and errors:
|
|
117
|
+
|
|
118
|
+
```python
|
|
119
|
+
client.request("GET", "/api/v1/services/forms/forms", query={"limit": 10})
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
## Documentation
|
|
123
|
+
|
|
124
|
+
- API reference: https://developer.softsolz.uk/api-reference.html
|
|
125
|
+
- Webhooks: https://developer.softsolz.uk/webhooks.html
|
|
126
|
+
- Playground: https://playground.softsolz.uk
|
|
127
|
+
|
|
128
|
+
## License
|
|
129
|
+
|
|
130
|
+
MIT
|
softsolz-0.1.0/README.md
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# softsolz
|
|
2
|
+
|
|
3
|
+
Official Python SDK for the [SoftSolz platform API](https://developer.softsolz.uk).
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
pip install softsolz
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
Requires Python 3.9 or later. Fully type-annotated (`py.typed`).
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
import os
|
|
15
|
+
from softsolz import SoftSolz
|
|
16
|
+
|
|
17
|
+
client = SoftSolz(api_key=os.environ["SOFTSOLZ_API_KEY"])
|
|
18
|
+
|
|
19
|
+
me = client.whoami()
|
|
20
|
+
|
|
21
|
+
form = client.forms.create_form({"name": "Contact Us", "status": "published"})
|
|
22
|
+
client.forms.submit_form(form["slug"], {"data": {"full_name": "Jane Doe", "email": "jane@example.com"}})
|
|
23
|
+
|
|
24
|
+
invoice = client.invoicing.create_invoice({
|
|
25
|
+
"customer_id": 42,
|
|
26
|
+
"lines": [{"description": "Design work", "quantity": 1, "unit_price_cents": 50000}],
|
|
27
|
+
})
|
|
28
|
+
client.invoicing.send_invoice(invoice["id"])
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Use an `sk_test_*` key to run against your sandbox workspace; swap to `sk_live_*` in production. No other configuration changes.
|
|
32
|
+
|
|
33
|
+
### Services
|
|
34
|
+
|
|
35
|
+
`client.blogs`, `client.customer_auth`, `client.forms`, `client.invoicing`, `client.payments`, `client.smart_chat`, `client.social`, `client.workflows` - one method per API operation, generated from the platform's service manifests.
|
|
36
|
+
|
|
37
|
+
### Pagination
|
|
38
|
+
|
|
39
|
+
List methods return a `Page` you can iterate; extra pages are fetched automatically:
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
for invoice in client.invoicing.list_invoices({"status": "overdue"}):
|
|
43
|
+
print(invoice["invoice_number"])
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Or page manually with `page.data`, `page.has_more`, and `page.next_page()`.
|
|
47
|
+
|
|
48
|
+
### Errors
|
|
49
|
+
|
|
50
|
+
All API failures raise a typed subclass of `SoftSolzError` carrying `status`, `code`, `message`, `details`, and `request_id`:
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
from softsolz import NotFoundError
|
|
54
|
+
|
|
55
|
+
try:
|
|
56
|
+
client.forms.get_form(999)
|
|
57
|
+
except NotFoundError as err:
|
|
58
|
+
print(err.code, err.request_id)
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Classes: `InvalidRequestError` (400/422), `AuthenticationError` (401), `PaymentRequiredError` (402), `PermissionDeniedError` (403), `NotFoundError` (404), `ConflictError` (409), `RateLimitError` (429, with `retry_after_seconds`), `APIConnectionError` (network).
|
|
62
|
+
|
|
63
|
+
### Retries and idempotency
|
|
64
|
+
|
|
65
|
+
429 and 5xx responses are retried automatically with exponential backoff (default 2 retries), honouring `Retry-After`. Every mutating request carries an auto-generated `Idempotency-Key`, so retries are always safe. Pass your own to control replays:
|
|
66
|
+
|
|
67
|
+
```python
|
|
68
|
+
client.invoicing.create_invoice(body, idempotency_key="order-1234")
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Configure per client: `SoftSolz(api_key=..., max_retries=3, timeout=60.0)`.
|
|
72
|
+
|
|
73
|
+
### Webhooks
|
|
74
|
+
|
|
75
|
+
Verify the `Softsolz-Signature` header on incoming webhooks using the raw request body:
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
from softsolz import webhooks
|
|
79
|
+
|
|
80
|
+
webhooks.assert_valid(
|
|
81
|
+
raw_body=request.get_data(),
|
|
82
|
+
header_value=request.headers.get("Softsolz-Signature"),
|
|
83
|
+
secret=os.environ["SOFTSOLZ_WEBHOOK_SECRET"],
|
|
84
|
+
)
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
`webhooks.verify(...)` returns `{"valid": False, "reason": ...}` if you prefer not to raise.
|
|
88
|
+
|
|
89
|
+
### Escape hatch
|
|
90
|
+
|
|
91
|
+
Call any endpoint directly while keeping auth, retries, and errors:
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
client.request("GET", "/api/v1/services/forms/forms", query={"limit": 10})
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## Documentation
|
|
98
|
+
|
|
99
|
+
- API reference: https://developer.softsolz.uk/api-reference.html
|
|
100
|
+
- Webhooks: https://developer.softsolz.uk/webhooks.html
|
|
101
|
+
- Playground: https://playground.softsolz.uk
|
|
102
|
+
|
|
103
|
+
## License
|
|
104
|
+
|
|
105
|
+
MIT
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "softsolz"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Official Python SDK for the SoftSolz platform API."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.9"
|
|
12
|
+
authors = [{ name = "SoftSolz" }]
|
|
13
|
+
keywords = ["softsolz", "sdk", "api", "forms", "invoicing", "payments", "webhooks"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"Operating System :: OS Independent",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3.9",
|
|
20
|
+
"Programming Language :: Python :: 3.10",
|
|
21
|
+
"Programming Language :: Python :: 3.11",
|
|
22
|
+
"Programming Language :: Python :: 3.12",
|
|
23
|
+
"Programming Language :: Python :: 3.13",
|
|
24
|
+
]
|
|
25
|
+
dependencies = ["httpx>=0.26,<1"]
|
|
26
|
+
|
|
27
|
+
[project.optional-dependencies]
|
|
28
|
+
dev = ["pytest>=8"]
|
|
29
|
+
|
|
30
|
+
[project.urls]
|
|
31
|
+
Homepage = "https://developer.softsolz.uk"
|
|
32
|
+
Documentation = "https://developer.softsolz.uk"
|
|
33
|
+
Repository = "https://github.com/soft-solz/sdk"
|
|
34
|
+
|
|
35
|
+
[tool.hatch.build.targets.wheel]
|
|
36
|
+
packages = ["src/softsolz"]
|
|
37
|
+
|
|
38
|
+
[tool.pytest.ini_options]
|
|
39
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
from . import webhooks
|
|
2
|
+
from ._client import SoftSolz
|
|
3
|
+
from ._http import DEFAULT_BASE_URL
|
|
4
|
+
from ._version import __version__
|
|
5
|
+
from .errors import (
|
|
6
|
+
APIConnectionError,
|
|
7
|
+
AuthenticationError,
|
|
8
|
+
ConflictError,
|
|
9
|
+
InvalidRequestError,
|
|
10
|
+
NotFoundError,
|
|
11
|
+
PaymentRequiredError,
|
|
12
|
+
PermissionDeniedError,
|
|
13
|
+
RateLimitError,
|
|
14
|
+
SoftSolzError,
|
|
15
|
+
WebhookSignatureError,
|
|
16
|
+
)
|
|
17
|
+
from .pagination import Page
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"SoftSolz",
|
|
21
|
+
"Page",
|
|
22
|
+
"webhooks",
|
|
23
|
+
"DEFAULT_BASE_URL",
|
|
24
|
+
"SoftSolzError",
|
|
25
|
+
"InvalidRequestError",
|
|
26
|
+
"AuthenticationError",
|
|
27
|
+
"PermissionDeniedError",
|
|
28
|
+
"PaymentRequiredError",
|
|
29
|
+
"NotFoundError",
|
|
30
|
+
"ConflictError",
|
|
31
|
+
"RateLimitError",
|
|
32
|
+
"APIConnectionError",
|
|
33
|
+
"WebhookSignatureError",
|
|
34
|
+
"__version__",
|
|
35
|
+
]
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
from typing import Any, Dict, Optional
|
|
2
|
+
|
|
3
|
+
import httpx
|
|
4
|
+
|
|
5
|
+
from ._http import DEFAULT_BASE_URL, HttpClient
|
|
6
|
+
from .resources import build_resources
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class SoftSolz:
|
|
10
|
+
def __init__(
|
|
11
|
+
self,
|
|
12
|
+
api_key: str,
|
|
13
|
+
*,
|
|
14
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
15
|
+
timeout: float = 30.0,
|
|
16
|
+
max_retries: int = 2,
|
|
17
|
+
transport: Optional[httpx.BaseTransport] = None,
|
|
18
|
+
) -> None:
|
|
19
|
+
self._http = HttpClient(
|
|
20
|
+
api_key,
|
|
21
|
+
base_url=base_url,
|
|
22
|
+
timeout=timeout,
|
|
23
|
+
max_retries=max_retries,
|
|
24
|
+
transport=transport,
|
|
25
|
+
)
|
|
26
|
+
for name, resource in build_resources(self._http).items():
|
|
27
|
+
setattr(self, name, resource)
|
|
28
|
+
|
|
29
|
+
def whoami(self, **options: Any) -> Any:
|
|
30
|
+
return self._http.request("GET", "/api/v1/whoami", options=options or None)
|
|
31
|
+
|
|
32
|
+
def services(self, **options: Any) -> Any:
|
|
33
|
+
return self._http.request("GET", "/api/v1/services", options=options or None)
|
|
34
|
+
|
|
35
|
+
def service_health(self, service_id: str, **options: Any) -> Any:
|
|
36
|
+
from ._http import _q
|
|
37
|
+
|
|
38
|
+
return self._http.request(
|
|
39
|
+
"GET", f"/api/v1/services/{_q(service_id)}/health", options=options or None
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
def request(
|
|
43
|
+
self,
|
|
44
|
+
method: str,
|
|
45
|
+
path: str,
|
|
46
|
+
*,
|
|
47
|
+
query: Optional[Dict[str, Any]] = None,
|
|
48
|
+
body: Optional[Dict[str, Any]] = None,
|
|
49
|
+
**options: Any,
|
|
50
|
+
) -> Any:
|
|
51
|
+
return self._http.request(method, path, query=query, body=body, options=options or None)
|
|
52
|
+
|
|
53
|
+
def close(self) -> None:
|
|
54
|
+
self._http.close()
|
|
55
|
+
|
|
56
|
+
def __enter__(self) -> "SoftSolz":
|
|
57
|
+
return self
|
|
58
|
+
|
|
59
|
+
def __exit__(self, *exc_info: Any) -> None:
|
|
60
|
+
self.close()
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import random
|
|
2
|
+
import time
|
|
3
|
+
import uuid
|
|
4
|
+
from typing import Any, Dict, Optional
|
|
5
|
+
from urllib.parse import quote
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from ._version import __version__
|
|
10
|
+
from .errors import APIConnectionError, error_from_response
|
|
11
|
+
from .pagination import Page
|
|
12
|
+
|
|
13
|
+
DEFAULT_BASE_URL = "https://app.softsolz.uk"
|
|
14
|
+
|
|
15
|
+
_MUTATING_METHODS = {"POST", "PUT", "PATCH", "DELETE"}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _q(value: Any) -> str:
|
|
19
|
+
return quote(str(value), safe="")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _backoff_seconds(attempt: int) -> float:
|
|
23
|
+
return min(8.0, 0.5 * (2**attempt)) + random.random() * 0.25
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class HttpClient:
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
api_key: str,
|
|
30
|
+
*,
|
|
31
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
32
|
+
timeout: float = 30.0,
|
|
33
|
+
max_retries: int = 2,
|
|
34
|
+
transport: Optional[httpx.BaseTransport] = None,
|
|
35
|
+
) -> None:
|
|
36
|
+
if not api_key or not isinstance(api_key, str):
|
|
37
|
+
raise ValueError('SoftSolz: api_key is required, e.g. SoftSolz(api_key="sk_test_...")')
|
|
38
|
+
self._max_retries = max_retries
|
|
39
|
+
self._client = httpx.Client(
|
|
40
|
+
base_url=base_url.rstrip("/"),
|
|
41
|
+
timeout=timeout,
|
|
42
|
+
transport=transport,
|
|
43
|
+
headers={
|
|
44
|
+
"authorization": f"Bearer {api_key}",
|
|
45
|
+
"accept": "application/json",
|
|
46
|
+
"user-agent": f"softsolz-python/{__version__}",
|
|
47
|
+
},
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
def envelope(
|
|
51
|
+
self,
|
|
52
|
+
method: str,
|
|
53
|
+
path: str,
|
|
54
|
+
*,
|
|
55
|
+
query: Optional[Dict[str, Any]] = None,
|
|
56
|
+
body: Optional[Dict[str, Any]] = None,
|
|
57
|
+
response_kind: str = "json",
|
|
58
|
+
options: Optional[Dict[str, Any]] = None,
|
|
59
|
+
) -> Dict[str, Any]:
|
|
60
|
+
options = options or {}
|
|
61
|
+
method = method.upper()
|
|
62
|
+
headers: Dict[str, str] = {}
|
|
63
|
+
if options.get("request_id"):
|
|
64
|
+
headers["x-request-id"] = str(options["request_id"])
|
|
65
|
+
if method in _MUTATING_METHODS:
|
|
66
|
+
headers["idempotency-key"] = str(options.get("idempotency_key") or uuid.uuid4())
|
|
67
|
+
params = {k: v for k, v in (query or {}).items() if v is not None}
|
|
68
|
+
max_retries = int(options.get("max_retries", self._max_retries))
|
|
69
|
+
timeout = options.get("timeout")
|
|
70
|
+
attempt = 0
|
|
71
|
+
while True:
|
|
72
|
+
try:
|
|
73
|
+
response = self._client.request(
|
|
74
|
+
method,
|
|
75
|
+
path,
|
|
76
|
+
params=params,
|
|
77
|
+
json=body,
|
|
78
|
+
headers=headers,
|
|
79
|
+
timeout=timeout if timeout is not None else httpx.USE_CLIENT_DEFAULT,
|
|
80
|
+
)
|
|
81
|
+
except httpx.HTTPError as exc:
|
|
82
|
+
if attempt < max_retries:
|
|
83
|
+
time.sleep(_backoff_seconds(attempt))
|
|
84
|
+
attempt += 1
|
|
85
|
+
continue
|
|
86
|
+
raise APIConnectionError(str(exc)) from exc
|
|
87
|
+
request_id = response.headers.get("softsolz-request-id")
|
|
88
|
+
if response.is_success:
|
|
89
|
+
if response_kind == "none" or response.status_code == 204:
|
|
90
|
+
return {"data": None, "meta": None, "request_id": request_id}
|
|
91
|
+
if response_kind == "text":
|
|
92
|
+
return {"data": response.text, "meta": None, "request_id": request_id}
|
|
93
|
+
if response_kind == "binary":
|
|
94
|
+
return {"data": response.content, "meta": None, "request_id": request_id}
|
|
95
|
+
payload = self._safe_json(response)
|
|
96
|
+
if isinstance(payload, dict) and "data" in payload:
|
|
97
|
+
data = payload.get("data")
|
|
98
|
+
meta = payload.get("meta") if isinstance(payload.get("meta"), dict) else None
|
|
99
|
+
else:
|
|
100
|
+
data = payload
|
|
101
|
+
meta = None
|
|
102
|
+
return {"data": data, "meta": meta, "request_id": request_id}
|
|
103
|
+
retryable = response.status_code == 429 or response.status_code >= 500
|
|
104
|
+
if retryable and attempt < max_retries:
|
|
105
|
+
retry_after = response.headers.get("retry-after")
|
|
106
|
+
if retry_after and retry_after.isdigit() and int(retry_after) > 0:
|
|
107
|
+
delay = float(retry_after)
|
|
108
|
+
else:
|
|
109
|
+
delay = _backoff_seconds(attempt)
|
|
110
|
+
time.sleep(delay)
|
|
111
|
+
attempt += 1
|
|
112
|
+
continue
|
|
113
|
+
raise error_from_response(response.status_code, self._safe_json(response), request_id)
|
|
114
|
+
|
|
115
|
+
def request(
|
|
116
|
+
self,
|
|
117
|
+
method: str,
|
|
118
|
+
path: str,
|
|
119
|
+
*,
|
|
120
|
+
query: Optional[Dict[str, Any]] = None,
|
|
121
|
+
body: Optional[Dict[str, Any]] = None,
|
|
122
|
+
response_kind: str = "json",
|
|
123
|
+
options: Optional[Dict[str, Any]] = None,
|
|
124
|
+
) -> Any:
|
|
125
|
+
return self.envelope(
|
|
126
|
+
method, path, query=query, body=body, response_kind=response_kind, options=options
|
|
127
|
+
)["data"]
|
|
128
|
+
|
|
129
|
+
def page(
|
|
130
|
+
self,
|
|
131
|
+
method: str,
|
|
132
|
+
path: str,
|
|
133
|
+
*,
|
|
134
|
+
query: Optional[Dict[str, Any]] = None,
|
|
135
|
+
options: Optional[Dict[str, Any]] = None,
|
|
136
|
+
) -> Page:
|
|
137
|
+
def fetch_page(offset: Optional[int]) -> Dict[str, Any]:
|
|
138
|
+
merged = dict(query or {})
|
|
139
|
+
if offset is not None:
|
|
140
|
+
merged["offset"] = offset
|
|
141
|
+
return self.envelope(method, path, query=merged, options=options)
|
|
142
|
+
|
|
143
|
+
return Page.create(fetch_page)
|
|
144
|
+
|
|
145
|
+
@staticmethod
|
|
146
|
+
def _safe_json(response: httpx.Response) -> Any:
|
|
147
|
+
try:
|
|
148
|
+
return response.json()
|
|
149
|
+
except ValueError:
|
|
150
|
+
return None
|
|
151
|
+
|
|
152
|
+
def close(self) -> None:
|
|
153
|
+
self._client.close()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
from typing import Any, Dict, Optional
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class SoftSolzError(Exception):
|
|
5
|
+
def __init__(
|
|
6
|
+
self,
|
|
7
|
+
message: str,
|
|
8
|
+
*,
|
|
9
|
+
status: int = 0,
|
|
10
|
+
code: str = "unknown_error",
|
|
11
|
+
details: Optional[Dict[str, Any]] = None,
|
|
12
|
+
request_id: Optional[str] = None,
|
|
13
|
+
) -> None:
|
|
14
|
+
super().__init__(message)
|
|
15
|
+
self.message = message
|
|
16
|
+
self.status = status
|
|
17
|
+
self.code = code
|
|
18
|
+
self.details = details
|
|
19
|
+
self.request_id = request_id
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class InvalidRequestError(SoftSolzError):
|
|
23
|
+
pass
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class AuthenticationError(SoftSolzError):
|
|
27
|
+
pass
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class PermissionDeniedError(SoftSolzError):
|
|
31
|
+
pass
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class PaymentRequiredError(SoftSolzError):
|
|
35
|
+
pass
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class NotFoundError(SoftSolzError):
|
|
39
|
+
pass
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class ConflictError(SoftSolzError):
|
|
43
|
+
pass
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class RateLimitError(SoftSolzError):
|
|
47
|
+
def __init__(self, message: str, *, retry_after_seconds: Optional[int] = None, **kwargs: Any) -> None:
|
|
48
|
+
super().__init__(message, **kwargs)
|
|
49
|
+
self.retry_after_seconds = retry_after_seconds
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class APIConnectionError(SoftSolzError):
|
|
53
|
+
def __init__(self, message: str) -> None:
|
|
54
|
+
super().__init__(message, status=0, code="connection_error")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class WebhookSignatureError(Exception):
|
|
58
|
+
def __init__(self, reason: str) -> None:
|
|
59
|
+
super().__init__(f"Webhook signature verification failed: {reason}")
|
|
60
|
+
self.reason = reason
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
_STATUS_TO_ERROR = {
|
|
64
|
+
400: InvalidRequestError,
|
|
65
|
+
401: AuthenticationError,
|
|
66
|
+
402: PaymentRequiredError,
|
|
67
|
+
403: PermissionDeniedError,
|
|
68
|
+
404: NotFoundError,
|
|
69
|
+
409: ConflictError,
|
|
70
|
+
422: InvalidRequestError,
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def error_from_response(status: int, body: Any, request_id: Optional[str] = None) -> SoftSolzError:
|
|
75
|
+
err = body.get("error") if isinstance(body, dict) else None
|
|
76
|
+
err = err if isinstance(err, dict) else {}
|
|
77
|
+
code = err.get("code") if isinstance(err.get("code"), str) else "unknown_error"
|
|
78
|
+
message = err.get("message") if isinstance(err.get("message"), str) else f"HTTP {status}"
|
|
79
|
+
details = err.get("details") if isinstance(err.get("details"), dict) else None
|
|
80
|
+
if status == 429:
|
|
81
|
+
retry_after = details.get("retry_after_seconds") if details else None
|
|
82
|
+
return RateLimitError(
|
|
83
|
+
message,
|
|
84
|
+
status=status,
|
|
85
|
+
code=code,
|
|
86
|
+
details=details,
|
|
87
|
+
request_id=request_id,
|
|
88
|
+
retry_after_seconds=retry_after if isinstance(retry_after, int) else None,
|
|
89
|
+
)
|
|
90
|
+
error_class = _STATUS_TO_ERROR.get(status, SoftSolzError)
|
|
91
|
+
return error_class(message, status=status, code=code, details=details, request_id=request_id)
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from typing import Any, Callable, Dict, Iterator, List, Optional
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class Page:
|
|
5
|
+
def __init__(
|
|
6
|
+
self,
|
|
7
|
+
data: List[Any],
|
|
8
|
+
meta: Optional[Dict[str, Any]],
|
|
9
|
+
fetch_page: Callable[[Optional[int]], Dict[str, Any]],
|
|
10
|
+
) -> None:
|
|
11
|
+
self.data = data
|
|
12
|
+
self.meta = meta
|
|
13
|
+
self._fetch_page = fetch_page
|
|
14
|
+
|
|
15
|
+
@classmethod
|
|
16
|
+
def create(cls, fetch_page: Callable[[Optional[int]], Dict[str, Any]]) -> "Page":
|
|
17
|
+
envelope = fetch_page(None)
|
|
18
|
+
return cls(envelope.get("data") or [], envelope.get("meta"), fetch_page)
|
|
19
|
+
|
|
20
|
+
@property
|
|
21
|
+
def has_more(self) -> bool:
|
|
22
|
+
return bool(self.meta) and self.meta.get("has_more") is True
|
|
23
|
+
|
|
24
|
+
def next_page(self) -> Optional["Page"]:
|
|
25
|
+
if not self.has_more or not self.meta:
|
|
26
|
+
return None
|
|
27
|
+
offset = int(self.meta.get("offset", 0)) + int(self.meta.get("limit", len(self.data)))
|
|
28
|
+
envelope = self._fetch_page(offset)
|
|
29
|
+
return Page(envelope.get("data") or [], envelope.get("meta"), self._fetch_page)
|
|
30
|
+
|
|
31
|
+
def __iter__(self) -> Iterator[Any]:
|
|
32
|
+
page: Optional[Page] = self
|
|
33
|
+
while page is not None:
|
|
34
|
+
for item in page.data:
|
|
35
|
+
yield item
|
|
36
|
+
page = page.next_page()
|
|
37
|
+
|
|
38
|
+
def auto_paging_iter(self) -> Iterator[Any]:
|
|
39
|
+
return iter(self)
|
|
File without changes
|