shepherd-sdk 0.2.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.
- shepherd/__init__.py +8 -0
- shepherd/client.py +405 -0
- shepherd/errors.py +43 -0
- shepherd/py.typed +0 -0
- shepherd/webhooks.py +29 -0
- shepherd_sdk-0.2.0.dist-info/METADATA +104 -0
- shepherd_sdk-0.2.0.dist-info/RECORD +8 -0
- shepherd_sdk-0.2.0.dist-info/WHEEL +4 -0
shepherd/__init__.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
from .client import Shepherd, DEFAULT_BASE_URL
|
|
2
|
+
from .errors import (AuthenticationError, ConflictError, InvalidRequestError, NotFoundError, PermissionDeniedError,
|
|
3
|
+
RateLimitError, ServerError, ShepherdError, SignatureVerificationError)
|
|
4
|
+
from .webhooks import construct_event
|
|
5
|
+
|
|
6
|
+
__all__ = ["Shepherd", "DEFAULT_BASE_URL", "ShepherdError", "AuthenticationError", "InvalidRequestError", "NotFoundError", "PermissionDeniedError", "ConflictError",
|
|
7
|
+
"RateLimitError", "ServerError", "SignatureVerificationError", "construct_event"]
|
|
8
|
+
__version__ = "0.2.0"
|
shepherd/client.py
ADDED
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
import time
|
|
2
|
+
|
|
3
|
+
import requests
|
|
4
|
+
|
|
5
|
+
from . import webhooks as _wh
|
|
6
|
+
from .errors import (AuthenticationError, ConflictError, InvalidRequestError, NotFoundError, PermissionDeniedError,
|
|
7
|
+
RateLimitError, ServerError, ShepherdError)
|
|
8
|
+
|
|
9
|
+
DEFAULT_BASE_URL = "https://app.getshepherd.co/api/v1"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _clean(d):
|
|
13
|
+
return {k: v for k, v in d.items() if v is not None}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class _Resource:
|
|
17
|
+
def __init__(self, client):
|
|
18
|
+
self._c = client
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Wallets(_Resource):
|
|
22
|
+
def list(self, end_user_id=None):
|
|
23
|
+
return self._c._get("/wallets", end_user_id=end_user_id)["wallets"]
|
|
24
|
+
|
|
25
|
+
def connect(self, address, chain, end_user_id=None, end_user_name=None, name=None, lookback_blocks=None):
|
|
26
|
+
"""Connect a wallet, pull its history, and auto-classify. chain: base, ethereum, polygon, arbitrum, tempo, solana."""
|
|
27
|
+
return self._c._post("/wallets", _clean(dict(address=address, chain=chain, end_user_id=end_user_id,
|
|
28
|
+
end_user_name=end_user_name, name=name, lookback_blocks=lookback_blocks)))
|
|
29
|
+
|
|
30
|
+
def scan(self, wallet_id, background=False):
|
|
31
|
+
"""Rescan a wallet. With background=True returns a job right away (see `jobs.wait`)."""
|
|
32
|
+
return self._c._post(f"/wallets/{wallet_id}/scan", {"async": True} if background else None)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class Transactions(_Resource):
|
|
36
|
+
def list(self, wallet_id=None, end_user_id=None, gl_code=None, **filters):
|
|
37
|
+
"""One page (up to `limit`, default 500). Use `iterate` to read everything, or `page` for the cursor.
|
|
38
|
+
filters: status, date_from, date_to, updated_since, limit, cursor, order."""
|
|
39
|
+
return self.page(wallet_id=wallet_id, end_user_id=end_user_id, gl_code=gl_code, **filters)["transactions"]
|
|
40
|
+
|
|
41
|
+
def page(self, **filters):
|
|
42
|
+
"""One page as {transactions, has_more, next_cursor}."""
|
|
43
|
+
return self._c._get("/transactions", **filters)
|
|
44
|
+
|
|
45
|
+
def iterate(self, **filters):
|
|
46
|
+
"""Yields every matching transaction, following next_cursor until the end."""
|
|
47
|
+
return self._c._iterate("/transactions", "transactions", filters)
|
|
48
|
+
|
|
49
|
+
def get(self, transaction_id):
|
|
50
|
+
return self._c._get(f"/transactions/{transaction_id}")
|
|
51
|
+
|
|
52
|
+
def classify(self, transaction_id, gl_code, counterparty=None, reason=None):
|
|
53
|
+
return self._c._post(f"/transactions/{transaction_id}/classify", _clean(dict(gl_code=gl_code, counterparty=counterparty, reason=reason)))
|
|
54
|
+
|
|
55
|
+
def link_suggestions(self):
|
|
56
|
+
return self._c._get("/transactions/link-suggestions")["suggestions"]
|
|
57
|
+
|
|
58
|
+
def link(self, tx_id_a, tx_id_b, note=None):
|
|
59
|
+
return self._c._post("/transactions/link", _clean(dict(tx_id_a=tx_id_a, tx_id_b=tx_id_b, note=note)))
|
|
60
|
+
|
|
61
|
+
def links(self):
|
|
62
|
+
return self._c._get("/transactions/links")["links"]
|
|
63
|
+
|
|
64
|
+
def unlink(self, link_id):
|
|
65
|
+
return self._c._post(f"/transactions/links/{link_id}/unlink")
|
|
66
|
+
|
|
67
|
+
def dismiss_link_suggestion(self, tx_id_a, tx_id_b):
|
|
68
|
+
return self._c._post("/transactions/link-suggestions/dismiss", dict(tx_id_a=tx_id_a, tx_id_b=tx_id_b))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class Statements(_Resource):
|
|
72
|
+
def income(self, start_date=None, end_date=None, entity_id=None, wallet_id=None, end_user_id=None):
|
|
73
|
+
return self._c._get("/financial-statements", start_date=start_date, end_date=end_date, entity_id=entity_id,
|
|
74
|
+
wallet_id=wallet_id, end_user_id=end_user_id)
|
|
75
|
+
|
|
76
|
+
def income_consolidated(self, start_date=None, end_date=None):
|
|
77
|
+
return self._c._get("/financial-statements/consolidated", start_date=start_date, end_date=end_date)
|
|
78
|
+
|
|
79
|
+
def balance_sheet(self, entity_id=None, as_of_date=None):
|
|
80
|
+
return self._c._get("/balance-sheet", entity_id=entity_id, as_of_date=as_of_date)
|
|
81
|
+
|
|
82
|
+
def balance_sheet_consolidated(self, as_of_date=None, period_start_date=None):
|
|
83
|
+
return self._c._get("/balance-sheet/consolidated", as_of_date=as_of_date, period_start_date=period_start_date)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class Entities(_Resource):
|
|
87
|
+
def list(self):
|
|
88
|
+
return self._c._get("/entities")["entities"]
|
|
89
|
+
|
|
90
|
+
def create(self, name, legal_name=None, entity_type=None, functional_currency=None):
|
|
91
|
+
return self._c._post("/entities", _clean(dict(name=name, legal_name=legal_name, entity_type=entity_type, functional_currency=functional_currency)))
|
|
92
|
+
|
|
93
|
+
def update(self, entity_id, name, legal_name=None, entity_type=None, functional_currency=None, clear_functional_currency=None):
|
|
94
|
+
return self._c._post(f"/entities/{entity_id}/update", _clean(dict(name=name, legal_name=legal_name, entity_type=entity_type,
|
|
95
|
+
functional_currency=functional_currency,
|
|
96
|
+
clear_functional_currency=clear_functional_currency)))
|
|
97
|
+
|
|
98
|
+
def delete(self, entity_id):
|
|
99
|
+
return self._c._post(f"/entities/{entity_id}/delete")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class OpeningBalances(_Resource):
|
|
103
|
+
def list(self):
|
|
104
|
+
return self._c._get("/opening-balances")["opening_balances"]
|
|
105
|
+
|
|
106
|
+
def create(self, account_name, account_type, amount, as_of_date, currency=None, gl_code=None):
|
|
107
|
+
return self._c._post("/opening-balances", _clean(dict(account_name=account_name, account_type=account_type, amount=amount,
|
|
108
|
+
as_of_date=as_of_date, currency=currency, gl_code=gl_code)))
|
|
109
|
+
|
|
110
|
+
def delete(self, balance_id):
|
|
111
|
+
return self._c._post(f"/opening-balances/{balance_id}/delete")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class Accruals(_Resource):
|
|
115
|
+
def list(self):
|
|
116
|
+
return self._c._get("/accruals")["accruals"]
|
|
117
|
+
|
|
118
|
+
def create(self, entry_type, amount, issue_date, **fields):
|
|
119
|
+
"""fields: gl_code, counterparty, description, due_date, recognition_start_date, recognition_months, external_source, external_id"""
|
|
120
|
+
return self._c._post("/accruals", _clean(dict(entry_type=entry_type, amount=amount, issue_date=issue_date, **fields)))
|
|
121
|
+
|
|
122
|
+
def match(self, accrual_id, tx_hash, wallet_address, chain, matched_date):
|
|
123
|
+
return self._c._post(f"/accruals/{accrual_id}/match", dict(tx_hash=tx_hash, wallet_address=wallet_address, chain=chain, matched_date=matched_date))
|
|
124
|
+
|
|
125
|
+
def delete(self, accrual_id):
|
|
126
|
+
return self._c._post(f"/accruals/{accrual_id}/delete")
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class ClassificationRules(_Resource):
|
|
130
|
+
def list(self):
|
|
131
|
+
return self._c._get("/classification-rules")["classification_rules"]
|
|
132
|
+
|
|
133
|
+
def create(self, gl_code, direction=None, chain=None, counterparty=None, amount_min=None, amount_max=None, entity_id=None):
|
|
134
|
+
return self._c._post("/classification-rules", _clean(dict(gl_code=gl_code, direction=direction, chain=chain, counterparty=counterparty,
|
|
135
|
+
amount_min=amount_min, amount_max=amount_max, entity_id=entity_id)))
|
|
136
|
+
|
|
137
|
+
def delete(self, rule_id):
|
|
138
|
+
return self._c._post(f"/classification-rules/{rule_id}/delete")
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class Reconciliation(_Resource):
|
|
142
|
+
def get(self, wallet_id):
|
|
143
|
+
return self._c._get("/reconciliation", wallet_id=wallet_id)
|
|
144
|
+
|
|
145
|
+
def set_baseline(self, wallet_id, balance):
|
|
146
|
+
return self._c._post("/reconciliation/baseline", dict(wallet_id=wallet_id, balance=balance))
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class Accounts(_Resource):
|
|
150
|
+
"""Accounts you push transactions to: bank, credit_card, stablecoin or ledger."""
|
|
151
|
+
|
|
152
|
+
def list(self, end_user_id=None):
|
|
153
|
+
return self._c._get("/accounts", end_user_id=end_user_id)["accounts"]
|
|
154
|
+
|
|
155
|
+
def create(self, external_id, name=None, type=None, currency=None, end_user_id=None, end_user_name=None, entity_id=None,
|
|
156
|
+
idempotency_key=None):
|
|
157
|
+
"""Creates the account, or returns the existing one with the same external_id."""
|
|
158
|
+
return self._c._post("/accounts", _clean(dict(external_id=external_id, name=name, type=type, currency=currency,
|
|
159
|
+
end_user_id=end_user_id, end_user_name=end_user_name, entity_id=entity_id)),
|
|
160
|
+
idempotency_key=idempotency_key)
|
|
161
|
+
|
|
162
|
+
def get(self, account_id):
|
|
163
|
+
return self._c._get(f"/accounts/{account_id}")
|
|
164
|
+
|
|
165
|
+
def push_transactions(self, account_id, transactions, classify=True, idempotency_key=None):
|
|
166
|
+
"""Send up to 1000 transactions: dicts with external_id, date, amount, and optionally direction, counterparty,
|
|
167
|
+
currency, category. Returns {created, duplicates, rejected, ids, classification}. Safe to resend."""
|
|
168
|
+
return self._c._post(f"/accounts/{account_id}/transactions", dict(transactions=transactions, classify=classify),
|
|
169
|
+
idempotency_key=idempotency_key)
|
|
170
|
+
|
|
171
|
+
def transactions(self, account_id, **filters):
|
|
172
|
+
"""Every transaction of one account, following pages."""
|
|
173
|
+
return self._c._iterate(f"/accounts/{account_id}/transactions", "transactions", filters)
|
|
174
|
+
|
|
175
|
+
def classify(self, account_id):
|
|
176
|
+
"""Classify the account's unclassified transactions in the background. Returns a job."""
|
|
177
|
+
return self._c._post(f"/accounts/{account_id}/classify")
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
class Jobs(_Resource):
|
|
181
|
+
def list(self):
|
|
182
|
+
return self._c._get("/jobs")["jobs"]
|
|
183
|
+
|
|
184
|
+
def get(self, job_id):
|
|
185
|
+
return self._c._get(f"/jobs/{job_id}")
|
|
186
|
+
|
|
187
|
+
def wait(self, job_id, timeout=300, interval=2):
|
|
188
|
+
"""Polls until the job succeeds (returns it) or fails (raises ShepherdError)."""
|
|
189
|
+
deadline = time.time() + timeout
|
|
190
|
+
while True:
|
|
191
|
+
job = self.get(job_id)
|
|
192
|
+
if job["status"] == "succeeded":
|
|
193
|
+
return job
|
|
194
|
+
if job["status"] == "failed":
|
|
195
|
+
raise ShepherdError(0, job.get("error") or "job failed", job)
|
|
196
|
+
if time.time() > deadline:
|
|
197
|
+
raise ShepherdError(0, f"job {job_id} still {job['status']} after {timeout}s", job)
|
|
198
|
+
time.sleep(interval)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
class ApiKeys(_Resource):
|
|
202
|
+
"""Needs an admin key."""
|
|
203
|
+
|
|
204
|
+
def list(self):
|
|
205
|
+
return self._c._get("/api-keys")["api_keys"]
|
|
206
|
+
|
|
207
|
+
def create(self, label=None, scope=None, expires_in_days=None, rate_limit_per_min=None):
|
|
208
|
+
"""scope: read, write or admin. The secret in `api_key` is shown once."""
|
|
209
|
+
return self._c._post("/api-keys", _clean(dict(label=label, scope=scope, expires_in_days=expires_in_days,
|
|
210
|
+
rate_limit_per_min=rate_limit_per_min)))
|
|
211
|
+
|
|
212
|
+
def rotate(self, key_id, grace_hours=None):
|
|
213
|
+
"""Issues a replacement; the old key keeps working for grace_hours (default 24)."""
|
|
214
|
+
return self._c._post(f"/api-keys/{key_id}/rotate", _clean(dict(grace_hours=grace_hours)))
|
|
215
|
+
|
|
216
|
+
def revoke(self, key_id):
|
|
217
|
+
return self._c._post(f"/api-keys/{key_id}/revoke")
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
class Workspaces(_Resource):
|
|
221
|
+
"""One separate set of books per customer. Use `shep.with_workspace(external_id)` to work inside one."""
|
|
222
|
+
|
|
223
|
+
def list(self):
|
|
224
|
+
return list(self._c._iterate("/workspaces", "workspaces", {}))
|
|
225
|
+
|
|
226
|
+
def create(self, external_id, name, contact_email=None, idempotency_key=None):
|
|
227
|
+
return self._c._post("/workspaces", _clean(dict(external_id=external_id, name=name, contact_email=contact_email)),
|
|
228
|
+
idempotency_key=idempotency_key)
|
|
229
|
+
|
|
230
|
+
def get(self, workspace_id):
|
|
231
|
+
return self._c._get(f"/workspaces/{workspace_id}")
|
|
232
|
+
|
|
233
|
+
def update(self, workspace_id, name):
|
|
234
|
+
return self._c._post(f"/workspaces/{workspace_id}/update", dict(name=name))
|
|
235
|
+
|
|
236
|
+
def delete(self, workspace_id, confirm):
|
|
237
|
+
"""Permanently deletes the workspace and its data. `confirm` must equal its external_id."""
|
|
238
|
+
return self._c._post(f"/workspaces/{workspace_id}/delete", dict(confirm=confirm))
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
class ChartOfAccounts(_Resource):
|
|
242
|
+
def list(self):
|
|
243
|
+
return self._c._get("/chart-of-accounts")["accounts"]
|
|
244
|
+
|
|
245
|
+
def add(self, accounts, replace=False):
|
|
246
|
+
"""accounts: dicts with code, name, account_type, description, parent_code. replace=True needs an admin key."""
|
|
247
|
+
return self._c._post("/chart-of-accounts", dict(accounts=accounts, replace=replace))
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
class Periods(_Resource):
|
|
251
|
+
def get(self):
|
|
252
|
+
return self._c._get("/periods")
|
|
253
|
+
|
|
254
|
+
def close(self, through, note=None):
|
|
255
|
+
return self._c._post("/periods/close", _clean(dict(through=through, note=note)))
|
|
256
|
+
|
|
257
|
+
def reopen(self, to, note):
|
|
258
|
+
return self._c._post("/periods/reopen", dict(to=to, note=note))
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
class AuditLog(_Resource):
|
|
262
|
+
def iterate(self, limit=None):
|
|
263
|
+
return self._c._iterate("/audit-log", "entries", _clean(dict(limit=limit)))
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
class Webhooks(_Resource):
|
|
267
|
+
def list(self):
|
|
268
|
+
return self._c._get("/webhooks")["webhooks"]
|
|
269
|
+
|
|
270
|
+
def create(self, url, events=None, description=None):
|
|
271
|
+
"""Returns the endpoint including its signing `secret`. The secret is shown once."""
|
|
272
|
+
return self._c._post("/webhooks", _clean(dict(url=url, events=events, description=description)))
|
|
273
|
+
|
|
274
|
+
def delete(self, webhook_id):
|
|
275
|
+
return self._c._post(f"/webhooks/{webhook_id}/delete")
|
|
276
|
+
|
|
277
|
+
def test(self, webhook_id):
|
|
278
|
+
"""Queue a `ping` event to this endpoint."""
|
|
279
|
+
return self._c._post(f"/webhooks/{webhook_id}/test")
|
|
280
|
+
|
|
281
|
+
def deliveries(self, webhook_id):
|
|
282
|
+
return self._c._get(f"/webhooks/{webhook_id}/deliveries")["deliveries"]
|
|
283
|
+
|
|
284
|
+
construct_event = staticmethod(_wh.construct_event)
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
class Sandbox(_Resource):
|
|
288
|
+
def reset(self):
|
|
289
|
+
"""Wipe this sandbox back to its seed data. Sandbox keys only."""
|
|
290
|
+
return self._c._post("/sandbox/reset")
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
class Shepherd:
|
|
294
|
+
def __init__(self, api_key, base_url=DEFAULT_BASE_URL, timeout=60, max_retries=2, workspace=None):
|
|
295
|
+
if not api_key:
|
|
296
|
+
raise ValueError("api_key is required")
|
|
297
|
+
self.api_key = api_key
|
|
298
|
+
self.base_url = base_url.rstrip("/")
|
|
299
|
+
self.timeout = timeout
|
|
300
|
+
self.max_retries = max_retries
|
|
301
|
+
self.workspace = workspace
|
|
302
|
+
self.livemode = not api_key.startswith("shep_test_")
|
|
303
|
+
self._s = requests.Session()
|
|
304
|
+
self._s.headers.update({"Authorization": f"Bearer {api_key}", "User-Agent": "shepherd-python/0.2.0"})
|
|
305
|
+
if workspace:
|
|
306
|
+
self._s.headers["Shepherd-Workspace"] = str(workspace)
|
|
307
|
+
self.wallets = Wallets(self)
|
|
308
|
+
self.transactions = Transactions(self)
|
|
309
|
+
self.statements = Statements(self)
|
|
310
|
+
self.entities = Entities(self)
|
|
311
|
+
self.opening_balances = OpeningBalances(self)
|
|
312
|
+
self.accruals = Accruals(self)
|
|
313
|
+
self.classification_rules = ClassificationRules(self)
|
|
314
|
+
self.reconciliation = Reconciliation(self)
|
|
315
|
+
self.webhooks = Webhooks(self)
|
|
316
|
+
self.sandbox = Sandbox(self)
|
|
317
|
+
self.accounts = Accounts(self)
|
|
318
|
+
self.jobs = Jobs(self)
|
|
319
|
+
self.api_keys = ApiKeys(self)
|
|
320
|
+
self.workspaces = Workspaces(self)
|
|
321
|
+
self.chart_of_accounts = ChartOfAccounts(self)
|
|
322
|
+
self.periods = Periods(self)
|
|
323
|
+
self.audit_log = AuditLog(self)
|
|
324
|
+
|
|
325
|
+
def with_workspace(self, external_id):
|
|
326
|
+
"""A client that works inside one customer's workspace (platform accounts)."""
|
|
327
|
+
return Shepherd(self.api_key, base_url=self.base_url, timeout=self.timeout, max_retries=self.max_retries, workspace=external_id)
|
|
328
|
+
|
|
329
|
+
def _iterate(self, path, key, params):
|
|
330
|
+
params = dict(params or {})
|
|
331
|
+
while True:
|
|
332
|
+
page = self._get(path, **params)
|
|
333
|
+
for item in page[key]:
|
|
334
|
+
yield item
|
|
335
|
+
if not page.get("has_more"):
|
|
336
|
+
return
|
|
337
|
+
params["cursor"] = page["next_cursor"]
|
|
338
|
+
|
|
339
|
+
@classmethod
|
|
340
|
+
def create_sandbox(cls, email=None, name=None, base_url=DEFAULT_BASE_URL):
|
|
341
|
+
"""Mint an instant sandbox key (no signup form) and return a client for it. The seed details are on `.sandbox_info`."""
|
|
342
|
+
r = requests.post(base_url.rstrip("/") + "/sandbox/signup", json=_clean(dict(email=email, name=name)), timeout=30)
|
|
343
|
+
body = _json(r)
|
|
344
|
+
if not r.ok:
|
|
345
|
+
raise _error(r.status_code, body)
|
|
346
|
+
client = cls(body["api_key"], base_url=base_url)
|
|
347
|
+
client.sandbox_info = body
|
|
348
|
+
return client
|
|
349
|
+
|
|
350
|
+
def _request(self, method, path, params=None, json_body=None, idempotency_key=None):
|
|
351
|
+
headers = {"Idempotency-Key": idempotency_key} if idempotency_key else None
|
|
352
|
+
retriable = method == "GET" or bool(idempotency_key) # a POST is only retried when it is safe to repeat
|
|
353
|
+
attempt = 0
|
|
354
|
+
while True:
|
|
355
|
+
try:
|
|
356
|
+
r = self._s.request(method, self.base_url + path, params=_clean(params or {}) or None, json=json_body,
|
|
357
|
+
headers=headers, timeout=self.timeout)
|
|
358
|
+
except requests.RequestException as e:
|
|
359
|
+
if retriable and attempt < self.max_retries:
|
|
360
|
+
attempt += 1
|
|
361
|
+
time.sleep(0.5 * 2 ** attempt)
|
|
362
|
+
continue
|
|
363
|
+
raise ShepherdError(0, f"network error: {e}")
|
|
364
|
+
body = _json(r)
|
|
365
|
+
if r.ok:
|
|
366
|
+
return body
|
|
367
|
+
if retriable and r.status_code in (429, 502, 503) and attempt < self.max_retries:
|
|
368
|
+
attempt += 1
|
|
369
|
+
try:
|
|
370
|
+
wait = min(30.0, float(r.headers.get("Retry-After")))
|
|
371
|
+
except (TypeError, ValueError):
|
|
372
|
+
wait = 0.5 * 2 ** attempt
|
|
373
|
+
time.sleep(wait)
|
|
374
|
+
continue
|
|
375
|
+
raise _error(r.status_code, body, r.headers)
|
|
376
|
+
|
|
377
|
+
def _get(self, path, **params):
|
|
378
|
+
return self._request("GET", path, params=params)
|
|
379
|
+
|
|
380
|
+
def _post(self, path, body=None, idempotency_key=None):
|
|
381
|
+
return self._request("POST", path, json_body=body if body is not None else {}, idempotency_key=idempotency_key)
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def _json(r):
|
|
385
|
+
try:
|
|
386
|
+
return r.json()
|
|
387
|
+
except ValueError:
|
|
388
|
+
return {"error": r.text[:200]}
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def _error(status, body, headers=None):
|
|
392
|
+
body = body or {}
|
|
393
|
+
msg = body.get("message") or body.get("error") or "request failed"
|
|
394
|
+
cls = {400: InvalidRequestError, 401: AuthenticationError, 403: PermissionDeniedError, 404: NotFoundError,
|
|
395
|
+
409: ConflictError, 413: InvalidRequestError, 422: InvalidRequestError, 429: RateLimitError}.get(status)
|
|
396
|
+
if cls is None:
|
|
397
|
+
cls = ServerError if status >= 500 else ShepherdError
|
|
398
|
+
err = cls(status, msg, body)
|
|
399
|
+
err.code = body.get("code")
|
|
400
|
+
err.request_id = body.get("request_id")
|
|
401
|
+
try:
|
|
402
|
+
err.retry_after = float((headers or {}).get("Retry-After"))
|
|
403
|
+
except (TypeError, ValueError):
|
|
404
|
+
err.retry_after = None
|
|
405
|
+
return err
|
shepherd/errors.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
class ShepherdError(Exception):
|
|
2
|
+
"""Raised for any non-2xx response. `status` is the HTTP status, `message` is the API's `error` text."""
|
|
3
|
+
|
|
4
|
+
def __init__(self, status, message, body=None):
|
|
5
|
+
super().__init__(f"{status}: {message}")
|
|
6
|
+
self.status = status
|
|
7
|
+
self.message = message
|
|
8
|
+
self.body = body or {}
|
|
9
|
+
self.code = self.body.get("code") # stable, safe to branch on, e.g. "period_closed"
|
|
10
|
+
self.request_id = self.body.get("request_id") # quote this when contacting Shepherd
|
|
11
|
+
self.retry_after = None # seconds, set on 429 and 503
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class AuthenticationError(ShepherdError):
|
|
15
|
+
"""401: missing, malformed, expired or revoked API key."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class PermissionDeniedError(ShepherdError):
|
|
19
|
+
"""403: the key's scope is too low, or the account can't use this feature."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ConflictError(ShepherdError):
|
|
23
|
+
"""409: for example the period is closed, or the same Idempotency-Key is still running."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class NotFoundError(ShepherdError):
|
|
27
|
+
"""404: the object doesn't exist in your organization."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class InvalidRequestError(ShepherdError):
|
|
31
|
+
"""400: a field is missing or invalid."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class RateLimitError(ShepherdError):
|
|
35
|
+
"""429: slow down."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class ServerError(ShepherdError):
|
|
39
|
+
"""5xx: Shepherd (or a chain data source) failed. Safe to retry."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class SignatureVerificationError(Exception):
|
|
43
|
+
"""A webhook payload failed signature verification."""
|
shepherd/py.typed
ADDED
|
File without changes
|
shepherd/webhooks.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import hmac
|
|
3
|
+
import json
|
|
4
|
+
import time
|
|
5
|
+
|
|
6
|
+
from .errors import SignatureVerificationError
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def construct_event(payload, signature_header, secret, tolerance=300):
|
|
10
|
+
"""Verify a webhook and return the parsed event.
|
|
11
|
+
|
|
12
|
+
payload the raw request body, exactly as received (bytes or str), not re-serialized JSON
|
|
13
|
+
signature_header the value of the `Shepherd-Signature` header
|
|
14
|
+
secret your endpoint's signing secret (whsec_...)
|
|
15
|
+
tolerance seconds a timestamp may differ from now; guards against replays (default 5 minutes)
|
|
16
|
+
"""
|
|
17
|
+
if isinstance(payload, bytes):
|
|
18
|
+
payload = payload.decode("utf-8")
|
|
19
|
+
try:
|
|
20
|
+
parts = dict(p.split("=", 1) for p in signature_header.split(","))
|
|
21
|
+
ts, sig = int(parts["t"]), parts["v1"]
|
|
22
|
+
except Exception:
|
|
23
|
+
raise SignatureVerificationError("Malformed Shepherd-Signature header")
|
|
24
|
+
expected = hmac.new(secret.encode(), f"{ts}.{payload}".encode(), hashlib.sha256).hexdigest()
|
|
25
|
+
if not hmac.compare_digest(expected, sig):
|
|
26
|
+
raise SignatureVerificationError("Signature does not match")
|
|
27
|
+
if tolerance and abs(time.time() - ts) > tolerance:
|
|
28
|
+
raise SignatureVerificationError("Timestamp outside the tolerance window")
|
|
29
|
+
return json.loads(payload)
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: shepherd-sdk
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Python client for the Shepherd API: connect wallets, classify transactions, and pull financial statements.
|
|
5
|
+
Project-URL: Homepage, https://getshepherd.co/developers.html
|
|
6
|
+
Author-email: Shepherd <alan@getshepherd.co>
|
|
7
|
+
License: MIT
|
|
8
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Typing :: Typed
|
|
11
|
+
Requires-Python: >=3.8
|
|
12
|
+
Requires-Dist: requests>=2.25
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
|
|
15
|
+
# shepherd-sdk (Python)
|
|
16
|
+
|
|
17
|
+
Python client for the [Shepherd API](https://getshepherd.co/developers.html): connect wallets, classify every transaction, and pull audit-ready financial statements.
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
pip install shepherd-sdk
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
```python
|
|
24
|
+
from shepherd import Shepherd
|
|
25
|
+
|
|
26
|
+
shep = Shepherd("shep_live_...") # or Shepherd.create_sandbox() for an instant test key
|
|
27
|
+
|
|
28
|
+
wallet = shep.wallets.connect("0x...", "base", end_user_id="cust_4821")
|
|
29
|
+
pnl = shep.statements.income(start_date="2026-09-01", end_date="2026-09-30", end_user_id="cust_4821")
|
|
30
|
+
print(pnl["net_income"])
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Sandbox
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
shep = Shepherd.create_sandbox(email="you@company.com")
|
|
37
|
+
print(shep.sandbox_info) # seeded wallet + customer
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Sandbox keys start with `shep_test_`. Data is synthetic; connecting any address returns generated activity.
|
|
41
|
+
|
|
42
|
+
## Webhooks
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
from shepherd import construct_event, SignatureVerificationError
|
|
46
|
+
|
|
47
|
+
# Flask example
|
|
48
|
+
@app.post("/shepherd/webhook")
|
|
49
|
+
def hook():
|
|
50
|
+
try:
|
|
51
|
+
event = construct_event(request.get_data(), request.headers["Shepherd-Signature"], WEBHOOK_SECRET)
|
|
52
|
+
except SignatureVerificationError:
|
|
53
|
+
return "", 400
|
|
54
|
+
if event["type"] == "wallet.scan_completed":
|
|
55
|
+
...
|
|
56
|
+
return "", 200
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Push your own transactions
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
acct = shep.accounts.create("acct_123", type="bank", currency="USD", end_user_id="cust_4821")
|
|
63
|
+
r = shep.accounts.push_transactions(acct["id"], [
|
|
64
|
+
{"external_id": "tx_1", "date": "2026-09-03", "amount": 1250.00, "direction": "inflow", "counterparty": "Acme Corp"},
|
|
65
|
+
])
|
|
66
|
+
print(r["created"], r["duplicates"], r["rejected"])
|
|
67
|
+
shep.jobs.wait(r["classification"]["job_id"]) # classification runs in the background
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Sending the same `external_id` again changes nothing, so it is safe to retry.
|
|
71
|
+
|
|
72
|
+
## Pagination
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
for tx in shep.transactions.iterate(status="unclassified"): # follows pages for you
|
|
76
|
+
...
|
|
77
|
+
changed = shep.transactions.iterate(updated_since="2026-10-01T00:00:00Z") # keep a copy in sync
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## One set of books per customer (platform accounts)
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
shep.workspaces.create("cust_4821", "Acme Corp")
|
|
84
|
+
acme = shep.with_workspace("cust_4821") # every call on `acme` reads and writes that customer only
|
|
85
|
+
acme.statements.income(start_date="2026-09-01")
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## Keys and safe retries
|
|
89
|
+
|
|
90
|
+
```python
|
|
91
|
+
key = shep.api_keys.create(label="reporting", scope="read", expires_in_days=90) # admin key needed
|
|
92
|
+
shep.api_keys.rotate(key["id"], grace_hours=24)
|
|
93
|
+
shep.accounts.create("acct_123", idempotency_key="create-acct_123") # POSTs with a key retry safely
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## Errors
|
|
97
|
+
|
|
98
|
+
Every failure raises a `ShepherdError` subclass with `.status`, `.message`, `.code`, `.request_id`, `.body`:
|
|
99
|
+
`InvalidRequestError` (400), `AuthenticationError` (401), `PermissionDeniedError` (403), `NotFoundError` (404), `ConflictError` (409), `RateLimitError` (429, with `.retry_after`), `ServerError` (5xx).
|
|
100
|
+
GET requests, and POSTs sent with an `idempotency_key`, retry automatically on 429/502/503 and honour `Retry-After`.
|
|
101
|
+
|
|
102
|
+
## Resources
|
|
103
|
+
|
|
104
|
+
`wallets`, `transactions`, `accounts`, `jobs`, `statements`, `entities`, `opening_balances`, `accruals`, `classification_rules`, `chart_of_accounts`, `periods`, `reconciliation`, `audit_log`, `api_keys`, `workspaces`, `webhooks`, `sandbox`. See the [API reference](https://getshepherd.co/developers-docs.html#reference).
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
shepherd/__init__.py,sha256=ZDQDWgSzjLISzvahEjlW9b-7e1PjXtt8l0OlYrF75t4,575
|
|
2
|
+
shepherd/client.py,sha256=HagY3VQqo4Nztl2Xo-NvUvtxZnc65xS0DQCVkOTInQ4,18277
|
|
3
|
+
shepherd/errors.py,sha256=BgHTG4aMOqzmSKl6Gzi_M9-7oRBMuMcYt0d_ijtQL1U,1451
|
|
4
|
+
shepherd/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
|
+
shepherd/webhooks.py,sha256=_jkhgCwQIAqErvqq6sZTmRPHs9jNSJhmcl3tdTdfGnw,1264
|
|
6
|
+
shepherd_sdk-0.2.0.dist-info/METADATA,sha256=Qk4T_az4cD1sRnM4A8NEJHAqG9NKWLcsYvCMwE4yo5E,3841
|
|
7
|
+
shepherd_sdk-0.2.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
8
|
+
shepherd_sdk-0.2.0.dist-info/RECORD,,
|