simpro-py-sdk 0.2.0__tar.gz → 0.3.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.
- {simpro_py_sdk-0.2.0 → simpro_py_sdk-0.3.0}/PKG-INFO +75 -8
- {simpro_py_sdk-0.2.0 → simpro_py_sdk-0.3.0}/README.md +74 -7
- simpro_py_sdk-0.3.0/docs/plans/2026-10-11-rate-limiting.md +1098 -0
- {simpro_py_sdk-0.2.0 → simpro_py_sdk-0.3.0}/pyproject.toml +1 -1
- {simpro_py_sdk-0.2.0 → simpro_py_sdk-0.3.0}/src/simpro/__init__.py +1 -1
- {simpro_py_sdk-0.2.0 → simpro_py_sdk-0.3.0}/src/simpro/client.py +66 -12
- {simpro_py_sdk-0.2.0 → simpro_py_sdk-0.3.0}/src/simpro/exceptions.py +18 -1
- {simpro_py_sdk-0.2.0 → simpro_py_sdk-0.3.0}/src/simpro/resources/__init__.py +3 -1
- {simpro_py_sdk-0.2.0 → simpro_py_sdk-0.3.0}/src/simpro/resources/_base.py +33 -1
- simpro_py_sdk-0.3.0/src/simpro/resources/quotes.py +17 -0
- simpro_py_sdk-0.3.0/src/simpro/resources/setup_webhooks.py +20 -0
- {simpro_py_sdk-0.2.0 → simpro_py_sdk-0.3.0}/tests/test_errors.py +28 -3
- simpro_py_sdk-0.3.0/tests/test_rate_limiting.py +233 -0
- {simpro_py_sdk-0.2.0 → simpro_py_sdk-0.3.0}/tests/test_resources.py +114 -30
- {simpro_py_sdk-0.2.0 → simpro_py_sdk-0.3.0}/uv.lock +1 -1
- simpro_py_sdk-0.2.0/tests/test_rate_limiting.py +0 -96
- {simpro_py_sdk-0.2.0 → simpro_py_sdk-0.3.0}/.github/workflows/ci.yml +0 -0
- {simpro_py_sdk-0.2.0 → simpro_py_sdk-0.3.0}/.github/workflows/publish.yml +0 -0
- {simpro_py_sdk-0.2.0 → simpro_py_sdk-0.3.0}/.gitignore +0 -0
- {simpro_py_sdk-0.2.0 → simpro_py_sdk-0.3.0}/.python-version +0 -0
- {simpro_py_sdk-0.2.0 → simpro_py_sdk-0.3.0}/LICENCE.txt +0 -0
- {simpro_py_sdk-0.2.0 → simpro_py_sdk-0.3.0}/src/simpro/_util.py +0 -0
- {simpro_py_sdk-0.2.0 → simpro_py_sdk-0.3.0}/src/simpro/py.typed +0 -0
- {simpro_py_sdk-0.2.0 → simpro_py_sdk-0.3.0}/src/simpro/ratelimit.py +0 -0
- {simpro_py_sdk-0.2.0 → simpro_py_sdk-0.3.0}/src/simpro/resources/companies.py +0 -0
- {simpro_py_sdk-0.2.0 → simpro_py_sdk-0.3.0}/src/simpro/resources/customers.py +0 -0
- {simpro_py_sdk-0.2.0 → simpro_py_sdk-0.3.0}/src/simpro/resources/jobs.py +0 -0
- {simpro_py_sdk-0.2.0 → simpro_py_sdk-0.3.0}/src/simpro/resources/leads.py +0 -0
- {simpro_py_sdk-0.2.0 → simpro_py_sdk-0.3.0}/src/simpro/resources/sites.py +0 -0
- {simpro_py_sdk-0.2.0 → simpro_py_sdk-0.3.0}/tests/test_client.py +0 -0
- {simpro_py_sdk-0.2.0 → simpro_py_sdk-0.3.0}/tests/test_pagination.py +0 -0
- {simpro_py_sdk-0.2.0 → simpro_py_sdk-0.3.0}/tests/test_ratelimit.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: simpro-py-sdk
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Python client for the SimPro Cloud REST API (v1.0).
|
|
5
5
|
Project-URL: Homepage, https://github.com/xuanpablo/simpro-py
|
|
6
6
|
Project-URL: Changelog, https://github.com/xuanpablo/simpro-py/releases
|
|
@@ -78,8 +78,8 @@ doubled-prefix 404 (`Invalid route`) cannot happen.
|
|
|
78
78
|
|
|
79
79
|
## Resources
|
|
80
80
|
|
|
81
|
-
|
|
82
|
-
resource, hung off the client:
|
|
81
|
+
The client ships read-only access to the first-slice resources, one
|
|
82
|
+
namespace per resource, hung off the client:
|
|
83
83
|
|
|
84
84
|
| Namespace | Endpoints |
|
|
85
85
|
|---|---|
|
|
@@ -87,6 +87,9 @@ resource, hung off the client:
|
|
|
87
87
|
| `client.customers` | `list(company_id, ...)`, `get(company_id, customer_id)`, `iter_all(company_id, **filters)` |
|
|
88
88
|
| `client.sites` | `list(company_id, ...)`, `get(company_id, site_id)`, `iter_all(company_id, **filters)` |
|
|
89
89
|
| `client.jobs` | `list(company_id, ...)`, `get(company_id, job_id)`, `iter_all(company_id, **filters)` |
|
|
90
|
+
| `client.leads` | `list(company_id, ...)`, `get(company_id, lead_id)`, `iter_all(company_id, **filters)` — list items are minimal; extra fields come via `columns="ID,LeadName,Stage"` |
|
|
91
|
+
| `client.quotes` | `list(company_id, ...)`, `get(company_id, quote_id)`, `iter_all(company_id, **filters)` — same `columns` projection as leads |
|
|
92
|
+
| `client.webhooks` | `list(company_id, ...)`, `get(company_id, webhook_id)`, `iter_all(company_id)`, `create(company_id, data)`, `update(company_id, webhook_id, data)`, `delete(company_id, webhook_id)` — webhook subscriptions (`setup/webhooks`), see below |
|
|
90
93
|
|
|
91
94
|
`company_id` is `0` unless your build uses SimPro multi-company.
|
|
92
95
|
|
|
@@ -125,6 +128,68 @@ since = last_sync_run_at # aware datetime; naive values are treated as UTC
|
|
|
125
128
|
new_customers = client.customers.list(0, modified_since=since)
|
|
126
129
|
```
|
|
127
130
|
|
|
131
|
+
## Webhooks
|
|
132
|
+
|
|
133
|
+
Subscriptions are fully API-managed per build/company — register one yourself
|
|
134
|
+
instead of asking a tenant admin:
|
|
135
|
+
|
|
136
|
+
```python
|
|
137
|
+
subscription = client.webhooks.create(
|
|
138
|
+
0,
|
|
139
|
+
{
|
|
140
|
+
"Name": "My integration",
|
|
141
|
+
"CallbackURL": "https://example.com/webhook/abc123",
|
|
142
|
+
"Secret": "shared-secret",
|
|
143
|
+
"Events": ["lead.created", "lead.updated", "lead.deleted"],
|
|
144
|
+
},
|
|
145
|
+
)
|
|
146
|
+
webhook_id = subscription["ID"]
|
|
147
|
+
client.webhooks.update(0, webhook_id, {"Events": ["lead.created"]})
|
|
148
|
+
client.webhooks.delete(0, webhook_id)
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
The delivery payload is thin — the event name and the record ids only, e.g.
|
|
152
|
+
`{"ID": "lead.created", "reference": {"companyID": 0, "leadID": 501}}` — so
|
|
153
|
+
fetch the record from the resource namespace named by the event.
|
|
154
|
+
|
|
155
|
+
Deliveries are at-least-once and can be paused after repeated failures, with
|
|
156
|
+
no documented replay API: keep a periodic poll beside the webhook.
|
|
157
|
+
|
|
158
|
+
## Rate limiting
|
|
159
|
+
|
|
160
|
+
SimPro enforces **10 API calls per second, per build and per IP** — a budget
|
|
161
|
+
shared across every integration and UI session hitting the build — and
|
|
162
|
+
answers over-limit traffic with `HTTP 429` (strictly enforced since April
|
|
163
|
+
2025).
|
|
164
|
+
|
|
165
|
+
The client keeps you inside that budget:
|
|
166
|
+
|
|
167
|
+
```python
|
|
168
|
+
client = SimproClient(base_url, api_key) # paced at 9 req/s
|
|
169
|
+
client = SimproClient(base_url, api_key, rate_limit=10) # right at the ceiling
|
|
170
|
+
client = SimproClient(base_url, api_key, rate_limit=None) # you handle pacing
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
- Pacing uses a sliding one-second window (default **9 requests/second**,
|
|
174
|
+
just under the ceiling to leave headroom for other traffic on the shared
|
|
175
|
+
budget) and adds no dependencies.
|
|
176
|
+
- The limit is per *build*, not per client: pass one shared limiter to
|
|
177
|
+
several clients on the same tenant so they count against a single budget.
|
|
178
|
+
|
|
179
|
+
```python
|
|
180
|
+
from simpro import SlidingWindowRateLimiter
|
|
181
|
+
|
|
182
|
+
limiter = SlidingWindowRateLimiter(9)
|
|
183
|
+
a = SimproClient(base_url, key_a, rate_limiter=limiter)
|
|
184
|
+
b = SimproClient(base_url, key_b, rate_limiter=limiter)
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
- When SimPro still answers `429` (other integrations can consume the
|
|
188
|
+
shared budget), the client retries automatically — up to `max_retries`
|
|
189
|
+
times (default 3), honouring `Retry-After` when present, otherwise
|
|
190
|
+
exponential backoff (1s, doubling, capped at 30s). Set `max_retries=0`
|
|
191
|
+
to disable retrying.
|
|
192
|
+
|
|
128
193
|
## Errors
|
|
129
194
|
|
|
130
195
|
All failures raise a subclass of `SimproError`:
|
|
@@ -134,7 +199,7 @@ All failures raise a subclass of `SimproError`:
|
|
|
134
199
|
| `SimproAuthError` | 401, 403 |
|
|
135
200
|
| `SimproNotFound` | 404 |
|
|
136
201
|
| `SimproValidationError` | 422 (`.details` holds the raw error list) |
|
|
137
|
-
| `SimproRateLimitError` | 429 |
|
|
202
|
+
| `SimproRateLimitError` | 429 after retries exhausted (`.retry_after` holds the parsed header, when sent) |
|
|
138
203
|
| `SimproError` | anything else, including transport failures |
|
|
139
204
|
|
|
140
205
|
SimPro 422 bodies are normally `{"errors": [{"path", "message", "value"}, ...]}`
|
|
@@ -143,10 +208,12 @@ but the `errors` key may be absent — both shapes are handled, and
|
|
|
143
208
|
|
|
144
209
|
## Deliberate omissions
|
|
145
210
|
|
|
146
|
-
- **No retry
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
- **No
|
|
211
|
+
- **No retry on transport errors** — only `429` is retried automatically
|
|
212
|
+
(see [Rate limiting](#rate-limiting)); connection resets and timeouts
|
|
213
|
+
remain the caller's job.
|
|
214
|
+
- **No caching.**
|
|
215
|
+
- **Writes are limited to webhook subscriptions** — `create`/`update`/`delete`
|
|
216
|
+
on `client.webhooks`; every other namespace is read-only.
|
|
150
217
|
- **No DTO layer** — responses are plain JSON dicts, so SDK releases never
|
|
151
218
|
lag the API.
|
|
152
219
|
|
|
@@ -36,8 +36,8 @@ doubled-prefix 404 (`Invalid route`) cannot happen.
|
|
|
36
36
|
|
|
37
37
|
## Resources
|
|
38
38
|
|
|
39
|
-
|
|
40
|
-
resource, hung off the client:
|
|
39
|
+
The client ships read-only access to the first-slice resources, one
|
|
40
|
+
namespace per resource, hung off the client:
|
|
41
41
|
|
|
42
42
|
| Namespace | Endpoints |
|
|
43
43
|
|---|---|
|
|
@@ -45,6 +45,9 @@ resource, hung off the client:
|
|
|
45
45
|
| `client.customers` | `list(company_id, ...)`, `get(company_id, customer_id)`, `iter_all(company_id, **filters)` |
|
|
46
46
|
| `client.sites` | `list(company_id, ...)`, `get(company_id, site_id)`, `iter_all(company_id, **filters)` |
|
|
47
47
|
| `client.jobs` | `list(company_id, ...)`, `get(company_id, job_id)`, `iter_all(company_id, **filters)` |
|
|
48
|
+
| `client.leads` | `list(company_id, ...)`, `get(company_id, lead_id)`, `iter_all(company_id, **filters)` — list items are minimal; extra fields come via `columns="ID,LeadName,Stage"` |
|
|
49
|
+
| `client.quotes` | `list(company_id, ...)`, `get(company_id, quote_id)`, `iter_all(company_id, **filters)` — same `columns` projection as leads |
|
|
50
|
+
| `client.webhooks` | `list(company_id, ...)`, `get(company_id, webhook_id)`, `iter_all(company_id)`, `create(company_id, data)`, `update(company_id, webhook_id, data)`, `delete(company_id, webhook_id)` — webhook subscriptions (`setup/webhooks`), see below |
|
|
48
51
|
|
|
49
52
|
`company_id` is `0` unless your build uses SimPro multi-company.
|
|
50
53
|
|
|
@@ -83,6 +86,68 @@ since = last_sync_run_at # aware datetime; naive values are treated as UTC
|
|
|
83
86
|
new_customers = client.customers.list(0, modified_since=since)
|
|
84
87
|
```
|
|
85
88
|
|
|
89
|
+
## Webhooks
|
|
90
|
+
|
|
91
|
+
Subscriptions are fully API-managed per build/company — register one yourself
|
|
92
|
+
instead of asking a tenant admin:
|
|
93
|
+
|
|
94
|
+
```python
|
|
95
|
+
subscription = client.webhooks.create(
|
|
96
|
+
0,
|
|
97
|
+
{
|
|
98
|
+
"Name": "My integration",
|
|
99
|
+
"CallbackURL": "https://example.com/webhook/abc123",
|
|
100
|
+
"Secret": "shared-secret",
|
|
101
|
+
"Events": ["lead.created", "lead.updated", "lead.deleted"],
|
|
102
|
+
},
|
|
103
|
+
)
|
|
104
|
+
webhook_id = subscription["ID"]
|
|
105
|
+
client.webhooks.update(0, webhook_id, {"Events": ["lead.created"]})
|
|
106
|
+
client.webhooks.delete(0, webhook_id)
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
The delivery payload is thin — the event name and the record ids only, e.g.
|
|
110
|
+
`{"ID": "lead.created", "reference": {"companyID": 0, "leadID": 501}}` — so
|
|
111
|
+
fetch the record from the resource namespace named by the event.
|
|
112
|
+
|
|
113
|
+
Deliveries are at-least-once and can be paused after repeated failures, with
|
|
114
|
+
no documented replay API: keep a periodic poll beside the webhook.
|
|
115
|
+
|
|
116
|
+
## Rate limiting
|
|
117
|
+
|
|
118
|
+
SimPro enforces **10 API calls per second, per build and per IP** — a budget
|
|
119
|
+
shared across every integration and UI session hitting the build — and
|
|
120
|
+
answers over-limit traffic with `HTTP 429` (strictly enforced since April
|
|
121
|
+
2025).
|
|
122
|
+
|
|
123
|
+
The client keeps you inside that budget:
|
|
124
|
+
|
|
125
|
+
```python
|
|
126
|
+
client = SimproClient(base_url, api_key) # paced at 9 req/s
|
|
127
|
+
client = SimproClient(base_url, api_key, rate_limit=10) # right at the ceiling
|
|
128
|
+
client = SimproClient(base_url, api_key, rate_limit=None) # you handle pacing
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
- Pacing uses a sliding one-second window (default **9 requests/second**,
|
|
132
|
+
just under the ceiling to leave headroom for other traffic on the shared
|
|
133
|
+
budget) and adds no dependencies.
|
|
134
|
+
- The limit is per *build*, not per client: pass one shared limiter to
|
|
135
|
+
several clients on the same tenant so they count against a single budget.
|
|
136
|
+
|
|
137
|
+
```python
|
|
138
|
+
from simpro import SlidingWindowRateLimiter
|
|
139
|
+
|
|
140
|
+
limiter = SlidingWindowRateLimiter(9)
|
|
141
|
+
a = SimproClient(base_url, key_a, rate_limiter=limiter)
|
|
142
|
+
b = SimproClient(base_url, key_b, rate_limiter=limiter)
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
- When SimPro still answers `429` (other integrations can consume the
|
|
146
|
+
shared budget), the client retries automatically — up to `max_retries`
|
|
147
|
+
times (default 3), honouring `Retry-After` when present, otherwise
|
|
148
|
+
exponential backoff (1s, doubling, capped at 30s). Set `max_retries=0`
|
|
149
|
+
to disable retrying.
|
|
150
|
+
|
|
86
151
|
## Errors
|
|
87
152
|
|
|
88
153
|
All failures raise a subclass of `SimproError`:
|
|
@@ -92,7 +157,7 @@ All failures raise a subclass of `SimproError`:
|
|
|
92
157
|
| `SimproAuthError` | 401, 403 |
|
|
93
158
|
| `SimproNotFound` | 404 |
|
|
94
159
|
| `SimproValidationError` | 422 (`.details` holds the raw error list) |
|
|
95
|
-
| `SimproRateLimitError` | 429 |
|
|
160
|
+
| `SimproRateLimitError` | 429 after retries exhausted (`.retry_after` holds the parsed header, when sent) |
|
|
96
161
|
| `SimproError` | anything else, including transport failures |
|
|
97
162
|
|
|
98
163
|
SimPro 422 bodies are normally `{"errors": [{"path", "message", "value"}, ...]}`
|
|
@@ -101,10 +166,12 @@ but the `errors` key may be absent — both shapes are handled, and
|
|
|
101
166
|
|
|
102
167
|
## Deliberate omissions
|
|
103
168
|
|
|
104
|
-
- **No retry
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
- **No
|
|
169
|
+
- **No retry on transport errors** — only `429` is retried automatically
|
|
170
|
+
(see [Rate limiting](#rate-limiting)); connection resets and timeouts
|
|
171
|
+
remain the caller's job.
|
|
172
|
+
- **No caching.**
|
|
173
|
+
- **Writes are limited to webhook subscriptions** — `create`/`update`/`delete`
|
|
174
|
+
on `client.webhooks`; every other namespace is read-only.
|
|
108
175
|
- **No DTO layer** — responses are plain JSON dicts, so SDK releases never
|
|
109
176
|
lag the API.
|
|
110
177
|
|