sendflo 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,50 @@
1
+ node_modules/
2
+ dist/
3
+ build/
4
+ .next/
5
+ out/
6
+ target/
7
+ target-shell-scratch/
8
+ src-tauri/target/
9
+ .turbo/
10
+ .env
11
+ .env.*
12
+ !.env.example
13
+ *.log
14
+ .DS_Store
15
+ Thumbs.db
16
+ .vs/
17
+ .vscode/
18
+
19
+ # Vercel
20
+ .vercel
21
+
22
+ # TypeScript incremental build cache — machine-local, changes on every typecheck.
23
+ *.tsbuildinfo
24
+
25
+ # Tauri generates these from tauri.conf.json + capabilities on every build.
26
+ apps/desktop/src-tauri/gen/schemas/
27
+
28
+ # Next static export for the desktop/mobile shells (generated by build:shell).
29
+ apps/web/out/
30
+ .next-shell/
31
+
32
+ # Capacitor-generated native projects. Regenerate with `pnpm --filter @sendflo/mobile android:setup`
33
+ # (which re-applies scripts/patch-android.mjs). Committing them means merge conflicts in
34
+ # generated Gradle files and a second copy of the plugin sources.
35
+ apps/mobile/android/
36
+ apps/mobile/ios/
37
+ apps/mobile/www/
38
+
39
+ # Tauri updater signing: the PRIVATE key never enters git (CI holds it as a secret).
40
+ apps/desktop/.tauri-signing/sendflo.key
41
+
42
+ # Northflank automation (local only — never commit)
43
+ .env.northflank
44
+ infrastructure/northflank/secrets.local.json
45
+ infrastructure/northflank/secrets.jackson.local.json
46
+ go/*.exe
47
+
48
+ # Python bytecode (packages/sdk-python tests)
49
+ __pycache__/
50
+ *.pyc
@@ -0,0 +1,8 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 — 2026-10-20
4
+
5
+ First public release.
6
+
7
+ - Parity with the TypeScript SDK: notification channels, recipient feedback, agent tasks,
8
+ `list_webhooks`, `delete_webhook`, `download_inbox_item`, `save_inbox_item`.
sendflo-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 SendFlo LLC
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.
sendflo-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,84 @@
1
+ Metadata-Version: 2.4
2
+ Name: sendflo
3
+ Version: 0.1.0
4
+ Summary: SendFlo for Python: send files, request files, manage transfers and AI agent tasks. Standard library only.
5
+ Project-URL: Homepage, https://sendflo.app
6
+ Project-URL: Documentation, https://sendflo.app/docs/cli#python
7
+ Author-email: SendFlo LLC <dev@sendflo.app>
8
+ License: MIT
9
+ License-File: LICENSE
10
+ Keywords: ai agents,file sharing,file transfer,sendflo,upload
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Topic :: Communications :: File Sharing
17
+ Requires-Python: >=3.9
18
+ Description-Content-Type: text/markdown
19
+
20
+ # sendflo
21
+
22
+ SendFlo for Python: send files and get a link, ask someone for a file, manage your transfers, and
23
+ give files to AI agents. Python 3.9+, **standard library only** — nothing else to install, so it
24
+ drops into locked-down agent containers.
25
+
26
+ ```sh
27
+ pip install sendflo
28
+ ```
29
+
30
+ Create an API key in SendFlo → Settings → Developer (<https://sendflo.app/settings/developer>)
31
+ and set `SENDFLO_API_KEY` (or pass `api_key=`).
32
+
33
+ ## Send a file
34
+
35
+ ```python
36
+ from sendflo import SendFlo
37
+
38
+ flo = SendFlo() # reads SENDFLO_API_KEY
39
+ sent = flo.send_file("report.pdf", password="optional")
40
+ print(sent.url) # https://sendflo.app/l/…
41
+ ```
42
+
43
+ The bytes go straight to storage through a presigned multipart upload, each part retried on its
44
+ own.
45
+
46
+ ## Ask someone for a file
47
+
48
+ ```python
49
+ ask = flo.request_file("Upload your Q3 expense CSV")
50
+ print(ask.url) # they upload there with no account
51
+
52
+ items = flo.list_inbox(inbox_id)
53
+ flo.save_inbox_item(items[0].id, "q3.csv")
54
+ ```
55
+
56
+ ## Manage what you've sent
57
+
58
+ `list_links()`, `revoke_link(id)`, and for a whole send: `get_transfer`, `pause_transfer`,
59
+ `resume_transfer`, `revoke_transfer`, `transfer_activity`, `transfer_audit`, `transfer_people`,
60
+ `set_transfer_person_access`. Recipient feedback: `link_feedback`, `link_feedback_comments`,
61
+ `export_link_feedback`.
62
+
63
+ ## Agents
64
+
65
+ `list_agents()`, `send_to_agent(cloud_agent_id, file_ids, instruction=…)`, `list_agent_tasks()`,
66
+ `get_agent_task(id)`, `answer_agent_task(…)`, `cancel_agent_task`, `retry_agent_task`,
67
+ `wait_for_agent_task(id)`.
68
+
69
+ ## Webhooks and notifications
70
+
71
+ `create_webhook(url, events)`, `list_webhooks()`, `delete_webhook(id)`, and
72
+ `verify_webhook_signature(secret, header, body)` for inbound deliveries (constant-time, with a
73
+ 5-minute replay window). Notification channels: `list_notification_channels`,
74
+ `add_notification_channel`, `update_notification_channel`, `remove_notification_channel`,
75
+ `test_notification_channel`.
76
+
77
+ ## Errors
78
+
79
+ Every non-2xx response raises `SendFloError` with `status`, a stable `code`, and `retryable` /
80
+ `out_of_credits`. Reads are retried with backoff; `402` (out of credits) never is.
81
+
82
+ ## Licence
83
+
84
+ MIT © SendFlo LLC. Questions: support@sendflo.app.
@@ -0,0 +1,65 @@
1
+ # sendflo
2
+
3
+ SendFlo for Python: send files and get a link, ask someone for a file, manage your transfers, and
4
+ give files to AI agents. Python 3.9+, **standard library only** — nothing else to install, so it
5
+ drops into locked-down agent containers.
6
+
7
+ ```sh
8
+ pip install sendflo
9
+ ```
10
+
11
+ Create an API key in SendFlo → Settings → Developer (<https://sendflo.app/settings/developer>)
12
+ and set `SENDFLO_API_KEY` (or pass `api_key=`).
13
+
14
+ ## Send a file
15
+
16
+ ```python
17
+ from sendflo import SendFlo
18
+
19
+ flo = SendFlo() # reads SENDFLO_API_KEY
20
+ sent = flo.send_file("report.pdf", password="optional")
21
+ print(sent.url) # https://sendflo.app/l/…
22
+ ```
23
+
24
+ The bytes go straight to storage through a presigned multipart upload, each part retried on its
25
+ own.
26
+
27
+ ## Ask someone for a file
28
+
29
+ ```python
30
+ ask = flo.request_file("Upload your Q3 expense CSV")
31
+ print(ask.url) # they upload there with no account
32
+
33
+ items = flo.list_inbox(inbox_id)
34
+ flo.save_inbox_item(items[0].id, "q3.csv")
35
+ ```
36
+
37
+ ## Manage what you've sent
38
+
39
+ `list_links()`, `revoke_link(id)`, and for a whole send: `get_transfer`, `pause_transfer`,
40
+ `resume_transfer`, `revoke_transfer`, `transfer_activity`, `transfer_audit`, `transfer_people`,
41
+ `set_transfer_person_access`. Recipient feedback: `link_feedback`, `link_feedback_comments`,
42
+ `export_link_feedback`.
43
+
44
+ ## Agents
45
+
46
+ `list_agents()`, `send_to_agent(cloud_agent_id, file_ids, instruction=…)`, `list_agent_tasks()`,
47
+ `get_agent_task(id)`, `answer_agent_task(…)`, `cancel_agent_task`, `retry_agent_task`,
48
+ `wait_for_agent_task(id)`.
49
+
50
+ ## Webhooks and notifications
51
+
52
+ `create_webhook(url, events)`, `list_webhooks()`, `delete_webhook(id)`, and
53
+ `verify_webhook_signature(secret, header, body)` for inbound deliveries (constant-time, with a
54
+ 5-minute replay window). Notification channels: `list_notification_channels`,
55
+ `add_notification_channel`, `update_notification_channel`, `remove_notification_channel`,
56
+ `test_notification_channel`.
57
+
58
+ ## Errors
59
+
60
+ Every non-2xx response raises `SendFloError` with `status`, a stable `code`, and `retryable` /
61
+ `out_of_credits`. Reads are retried with backoff; `402` (out of credits) never is.
62
+
63
+ ## Licence
64
+
65
+ MIT © SendFlo LLC. Questions: support@sendflo.app.
@@ -0,0 +1,34 @@
1
+ [project]
2
+ name = "sendflo"
3
+ version = "0.1.0"
4
+ description = "SendFlo for Python: send files, request files, manage transfers and AI agent tasks. Standard library only."
5
+ readme = "README.md"
6
+ requires-python = ">=3.9"
7
+ license = { text = "MIT" }
8
+ authors = [{ name = "SendFlo LLC", email = "dev@sendflo.app" }]
9
+ keywords = ["sendflo", "file transfer", "file sharing", "upload", "ai agents"]
10
+ classifiers = [
11
+ "Development Status :: 4 - Beta",
12
+ "Intended Audience :: Developers",
13
+ "License :: OSI Approved :: MIT License",
14
+ "Operating System :: OS Independent",
15
+ "Programming Language :: Python :: 3",
16
+ "Topic :: Communications :: File Sharing",
17
+ ]
18
+ # Deliberately empty. Agent runtimes are often locked-down containers where adding a
19
+ # dependency is a conversation with someone's security team; stdlib-only makes this a drop-in.
20
+ dependencies = []
21
+
22
+ [project.urls]
23
+ Homepage = "https://sendflo.app"
24
+ Documentation = "https://sendflo.app/docs/cli#python"
25
+
26
+ [build-system]
27
+ requires = ["hatchling==1.27.0"]
28
+ build-backend = "hatchling.build"
29
+
30
+ [tool.hatch.build.targets.wheel]
31
+ packages = ["sendflo"]
32
+
33
+ [tool.hatch.build.targets.sdist]
34
+ include = ["sendflo", "tests", "README.md", "LICENSE", "CHANGELOG.md"]
@@ -0,0 +1,500 @@
1
+ """
2
+ SendFlo Python SDK (PLAN 3.6).
3
+
4
+ Handwritten for the same reason as the TypeScript one: the call people want — ``send_file`` —
5
+ spans three endpoints plus a presigned multipart upload with per-part retries, and a generated
6
+ client would hand all of that back to the caller.
7
+
8
+ Standard library only. Agent runtimes are frequently locked-down containers where adding
9
+ ``requests`` is a conversation with someone's security team, and this needs to be a drop-in.
10
+
11
+ from sendflo import SendFlo
12
+
13
+ flo = SendFlo(api_key=os.environ["SENDFLO_API_KEY"])
14
+ link = flo.send_file("report.pdf")
15
+ ask = flo.request_file("Upload your Q3 expense CSV")
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import hashlib
21
+ import hmac
22
+ import json
23
+ import os
24
+ import random
25
+ import time
26
+ import urllib.error
27
+ import urllib.parse
28
+ import urllib.request
29
+ from dataclasses import dataclass
30
+ from datetime import datetime, timedelta, timezone
31
+ from typing import Any, BinaryIO, Callable, Iterable
32
+
33
+ __all__ = [
34
+ "SendFlo",
35
+ "SendFloError",
36
+ "SendResult",
37
+ "FileRequest",
38
+ "InboxItem",
39
+ "verify_webhook_signature",
40
+ ]
41
+
42
+ DEFAULT_BASE_URL = "https://api.sendflo.app"
43
+ __version__ = "0.1.0"
44
+ _FINAL_TASK_STATES = frozenset({"completed", "failed", "canceled", "rejected", "expired"})
45
+
46
+
47
+ class SendFloError(Exception):
48
+ """Every non-2xx response. Callers branch on ``code``, never on the message text."""
49
+
50
+ def __init__(self, status: int, code: str, message: str, detail: str | None = None) -> None:
51
+ super().__init__(message)
52
+ self.status = status
53
+ self.code = code
54
+ self.detail = detail
55
+
56
+ @property
57
+ def retryable(self) -> bool:
58
+ """True when retrying the same call could plausibly succeed."""
59
+ return self.status == 429 or self.status >= 500
60
+
61
+ @property
62
+ def out_of_credits(self) -> bool:
63
+ """The one error an agent must handle explicitly: the operation did NOT happen."""
64
+ return self.status == 402
65
+
66
+
67
+ @dataclass
68
+ class SendResult:
69
+ file_id: str
70
+ link_id: str
71
+ slug: str
72
+ url: str
73
+
74
+
75
+ @dataclass
76
+ class FileRequest:
77
+ id: str
78
+ slug: str
79
+ url: str
80
+ state: str
81
+ prompt: str | None = None
82
+ expires_at: str | None = None
83
+
84
+
85
+ @dataclass
86
+ class InboxItem:
87
+ id: str
88
+ file_id: str
89
+ name: str
90
+ size: int
91
+ content_type: str | None
92
+ scan_state: str
93
+ sender: str | None
94
+ received_at: str
95
+
96
+
97
+ def _backoff(attempt: int) -> float:
98
+ """Exponential with full jitter — a fleet of agents retrying in lockstep is a self-inflicted
99
+ thundering herd."""
100
+ return random.random() * min(30.0, 0.5 * (2**attempt))
101
+
102
+
103
+ class SendFlo:
104
+ def __init__(
105
+ self,
106
+ api_key: str | None = None,
107
+ base_url: str | None = None,
108
+ max_retries: int = 3,
109
+ timeout: float = 30.0,
110
+ ) -> None:
111
+ key = api_key or os.environ.get("SENDFLO_API_KEY")
112
+ if not key:
113
+ raise ValueError("SendFlo: api_key is required (or set SENDFLO_API_KEY)")
114
+ self.api_key = key
115
+ self.base_url = (base_url or os.environ.get("SENDFLO_BASE_URL") or DEFAULT_BASE_URL).rstrip("/")
116
+ self.max_retries = max_retries
117
+ self.timeout = timeout
118
+
119
+ # ── core request ──────────────────────────────────────────────────────────
120
+
121
+ def _request(
122
+ self,
123
+ method: str,
124
+ path: str,
125
+ body: dict[str, Any] | None = None,
126
+ idempotency_key: str | None = None,
127
+ ) -> Any:
128
+ headers = {"authorization": f"Bearer {self.api_key}", "accept": "application/json"}
129
+ data = None
130
+ if body is not None:
131
+ # Drop None values so optional params are genuinely absent rather than explicit null.
132
+ payload = {k: v for k, v in body.items() if v is not None}
133
+ data = json.dumps(payload).encode()
134
+ headers["content-type"] = "application/json"
135
+ if idempotency_key:
136
+ headers["idempotency-key"] = idempotency_key
137
+
138
+ last_error: Exception | None = None
139
+ for attempt in range(self.max_retries + 1):
140
+ req = urllib.request.Request(f"{self.base_url}{path}", data=data, headers=headers, method=method)
141
+ try:
142
+ with urllib.request.urlopen(req, timeout=self.timeout) as res:
143
+ raw = res.read()
144
+ return json.loads(raw) if raw else None
145
+ except urllib.error.HTTPError as e:
146
+ problem: dict[str, Any] = {}
147
+ try:
148
+ problem = json.loads(e.read())
149
+ except Exception:
150
+ pass
151
+ error = SendFloError(
152
+ e.code,
153
+ problem.get("code", f"http_{e.code}"),
154
+ problem.get("title", e.reason or "request failed"),
155
+ problem.get("detail"),
156
+ )
157
+ # 402 is terminal — retrying an out-of-credits call only burns rate limit.
158
+ if not error.retryable or attempt == self.max_retries:
159
+ raise error from None
160
+ retry_after = e.headers.get("retry-after") if e.headers else None
161
+ time.sleep(float(retry_after) if retry_after and retry_after.isdigit() else _backoff(attempt))
162
+ last_error = error
163
+ except urllib.error.URLError as e:
164
+ # Without an idempotency key we cannot know whether a non-GET was processed,
165
+ # so retrying it risks a duplicate. GETs are always safe.
166
+ if method != "GET" and not idempotency_key:
167
+ raise
168
+ if attempt == self.max_retries:
169
+ raise
170
+ last_error = e
171
+ time.sleep(_backoff(attempt))
172
+ raise last_error or RuntimeError("SendFlo: request failed")
173
+
174
+ # ── sending ───────────────────────────────────────────────────────────────
175
+
176
+ def send_file(
177
+ self,
178
+ file: str | BinaryIO,
179
+ name: str | None = None,
180
+ expires_in_days: int = 7,
181
+ password: str | None = None,
182
+ max_downloads: int | None = None,
183
+ note: str | None = None,
184
+ on_progress: Callable[[int, int], None] | None = None,
185
+ ) -> SendResult:
186
+ """Upload and return a shareable link.
187
+
188
+ Bytes go straight to object storage via presigned multipart — they never pass through
189
+ the API, which is why a 40 GB send doesn't depend on how much memory the API has.
190
+ """
191
+ if isinstance(file, str):
192
+ path = file
193
+ size = os.path.getsize(path)
194
+ name = name or os.path.basename(path)
195
+ handle: BinaryIO = open(path, "rb")
196
+ close_after = True
197
+ else:
198
+ handle, close_after = file, False
199
+ handle.seek(0, os.SEEK_END)
200
+ size = handle.tell()
201
+ handle.seek(0)
202
+ name = name or getattr(handle, "name", "file")
203
+ name = os.path.basename(str(name))
204
+
205
+ if size == 0:
206
+ if close_after:
207
+ handle.close()
208
+ raise ValueError("SendFlo: cannot send a zero-byte file")
209
+
210
+ expires_at = (datetime.now(timezone.utc) + timedelta(days=expires_in_days)).isoformat()
211
+
212
+ try:
213
+ created = self._request("POST", "/v1/transfers", {"name": name, "size": size, "expires_at": expires_at})
214
+ upload = created["upload"]
215
+ part_size = upload["part_size"]
216
+ part_urls: list[str] = upload["part_urls"]
217
+
218
+ parts = []
219
+ sent = 0
220
+ for index, url in enumerate(part_urls):
221
+ chunk = handle.read(part_size)
222
+ etag = self._put_part(url, chunk)
223
+ parts.append({"part_number": index + 1, "etag": etag})
224
+ sent += len(chunk)
225
+ if on_progress:
226
+ on_progress(sent, size)
227
+
228
+ completed = self._request("POST", f"/v1/transfers/{created['id']}/complete", {"parts": parts})
229
+ link = self._request(
230
+ "POST",
231
+ "/v1/links",
232
+ {
233
+ "file_id": completed["file_id"],
234
+ "password": password,
235
+ "expires_at": expires_at,
236
+ "max_downloads": max_downloads,
237
+ "note": note,
238
+ },
239
+ )
240
+ return SendResult(completed["file_id"], link["id"], link["slug"], link["url"])
241
+ finally:
242
+ if close_after:
243
+ handle.close()
244
+
245
+ def _put_part(self, url: str, chunk: bytes) -> str:
246
+ """A part is idempotent by definition — the same part number overwrites — so retrying
247
+ one is always safe, and far cheaper than restarting the upload."""
248
+ last: Exception | None = None
249
+ for attempt in range(self.max_retries + 1):
250
+ try:
251
+ req = urllib.request.Request(url, data=chunk, method="PUT")
252
+ with urllib.request.urlopen(req, timeout=self.timeout) as res:
253
+ etag = res.headers.get("etag")
254
+ if not etag:
255
+ # Failing here names the cause; the alternative is a confusing error at
256
+ # multipart completion, far from the actual misconfiguration.
257
+ raise SendFloError(
258
+ 0,
259
+ "etag_missing",
260
+ "Storage did not return an ETag",
261
+ "The bucket's CORS policy must include ETag in ExposeHeaders.",
262
+ )
263
+ return etag.strip('"')
264
+ except SendFloError:
265
+ raise
266
+ except Exception as e:
267
+ last = e
268
+ if attempt == self.max_retries:
269
+ raise
270
+ time.sleep(_backoff(attempt))
271
+ raise last or RuntimeError("part upload failed")
272
+
273
+ # ── agent surfaces ────────────────────────────────────────────────────────
274
+
275
+ def request_file(
276
+ self,
277
+ prompt: str,
278
+ max_files: int = 1,
279
+ max_bytes_per_file: int | None = None,
280
+ deliver_to_inbox_id: str | None = None,
281
+ expires_in_days: int = 7,
282
+ idempotency_key: str | None = None,
283
+ ) -> FileRequest:
284
+ """Ask a HUMAN for a file. Returns a URL they can upload to with no account."""
285
+ res = self._request(
286
+ "POST",
287
+ "/v1/file-requests",
288
+ {
289
+ "prompt": prompt,
290
+ "max_files": max_files,
291
+ "max_bytes_per_file": max_bytes_per_file,
292
+ "deliver_to_inbox_id": deliver_to_inbox_id,
293
+ "expires_at": (datetime.now(timezone.utc) + timedelta(days=expires_in_days)).isoformat(),
294
+ },
295
+ idempotency_key=idempotency_key,
296
+ )
297
+ return FileRequest(res["id"], res["slug"], res["url"], res["state"], res.get("prompt"), res.get("expires_at"))
298
+
299
+ def get_file_request(self, request_id: str) -> FileRequest:
300
+ res = self._request("GET", f"/v1/file-requests/{request_id}")
301
+ return FileRequest(res["id"], res["slug"], res["url"], res["state"], res.get("prompt"), res.get("expires_at"))
302
+
303
+ def create_inbox(self, name: str | None = None, ttl_days: int = 7) -> dict[str, Any]:
304
+ return self._request("POST", "/v1/inboxes", {"name": name, "default_ttl_seconds": ttl_days * 86400})
305
+
306
+ def list_inbox(self, inbox_id: str) -> list[InboxItem]:
307
+ res = self._request("GET", f"/v1/inboxes/{inbox_id}/items")
308
+ return [
309
+ InboxItem(
310
+ i["id"],
311
+ i["file"]["id"],
312
+ i["file"]["name"],
313
+ int(i["file"]["size"]),
314
+ i["file"].get("content_type"),
315
+ i["file"]["scan_state"],
316
+ i.get("from"),
317
+ i["received_at"],
318
+ )
319
+ for i in res["data"]
320
+ ]
321
+
322
+ def list_links(self) -> list[dict[str, Any]]:
323
+ return self._request("GET", "/v1/links")["data"]
324
+
325
+ def list_links_page(self, cursor: str | None = None, limit: int | None = None) -> dict[str, Any]:
326
+ """One page of your links, newest first (revoked ones included); pass next_cursor back."""
327
+ q = urllib.parse.urlencode({k: v for k, v in {"cursor": cursor, "limit": limit}.items() if v is not None})
328
+ return self._request("GET", f"/v1/links{'?' + q if q else ''}")
329
+
330
+ def revoke_link(self, link_id: str) -> None:
331
+ """Revokes ONE link (one file) and frees its storage. revoke_transfer ends a whole send."""
332
+ self._request("DELETE", f"/v1/links/{link_id}")
333
+
334
+ # Whole transfers (Wave 1 S6) — link_id is any link of the send.
335
+
336
+ def get_transfer(self, link_id: str) -> dict[str, Any]:
337
+ return self._request("GET", f"/v1/links/{link_id}/transfer")
338
+
339
+ def pause_transfer(self, link_id: str) -> dict[str, Any]:
340
+ """Reversible: every file refuses with "Paused by the sender"; the files keep their storage."""
341
+ return self._request("PUT", f"/v1/links/{link_id}/transfer/pause")
342
+
343
+ def resume_transfer(self, link_id: str) -> dict[str, Any]:
344
+ """Lifts your own pause (never a safety review's)."""
345
+ return self._request("DELETE", f"/v1/links/{link_id}/transfer/pause")
346
+
347
+ def revoke_transfer(self, link_id: str) -> dict[str, Any]:
348
+ """Ends every file of the send for good and frees the storage. Can't be undone."""
349
+ return self._request("POST", f"/v1/links/{link_id}/transfer/revoke")
350
+
351
+ def transfer_activity(self, link_id: str, cursor: "str | None" = None, limit: "int | None" = None) -> dict[str, Any]:
352
+ """Who opened what, when and from where across the whole send (Wave 1 T1).
353
+ `people` comes on the first page; pass the previous `next_cursor` as `cursor` for older events."""
354
+ params = {k: v for k, v in (("cursor", cursor), ("limit", limit)) if v is not None}
355
+ qs = "&".join(f"{k}={v}" for k, v in params.items())
356
+ return self._request("GET", f"/v1/links/{link_id}/transfer/activity" + (f"?{qs}" if qs else ""))
357
+
358
+ def transfer_audit(self, link_id: str) -> dict[str, Any]:
359
+ """Everything that happened to the send, for an auditor or a client (Wave 1 T2), as JSON.
360
+ The CSV form is GET /v1/links/{id}/transfer/audit?format=csv."""
361
+ return self._request("GET", f"/v1/links/{link_id}/transfer/audit?format=json")
362
+
363
+ def transfer_people(self, link_id: str) -> dict[str, Any]:
364
+ """Everyone who can open the send and which files each can open (Wave 1 R6)."""
365
+ return self._request("GET", f"/v1/links/{link_id}/transfer/people")
366
+
367
+ def set_transfer_person_access(self, link_id: str, email: str, files: "str | list[str]") -> dict[str, Any]:
368
+ """Exactly which files `email` may open: "all", or link ids (every other file is removed; [] = the whole send).
369
+ Only restores addresses you removed; it never adds anyone. A Public send answers 409 `public_link`."""
370
+ return self._request("POST", f"/v1/links/{link_id}/transfer/people/access", {"email": email, "files": files})
371
+
372
+ def create_webhook(self, url: str, events: Iterable[str]) -> dict[str, Any]:
373
+ return self._request("POST", "/v1/webhooks", {"url": url, "events": list(events)})
374
+
375
+ def list_webhooks(self) -> list[dict[str, Any]]:
376
+ """The workspace's webhook endpoints (never their secrets), including any switched off after failures."""
377
+ return self._request("GET", "/v1/webhooks")["data"]
378
+
379
+ def delete_webhook(self, webhook_id: str) -> None:
380
+ self._request("DELETE", f"/v1/webhooks/{urllib.parse.quote(webhook_id, safe='')}")
381
+
382
+ # Received files (inboxes and upload pages).
383
+
384
+ def download_inbox_item(self, item_id: str) -> dict[str, Any]:
385
+ """A short-lived URL for a received file (scanned, not blocked): ``{"url", "name", "size"}``."""
386
+ res = self._request("POST", f"/v1/inbox-items/{urllib.parse.quote(item_id, safe='')}/download")
387
+ return {"url": res["url"], "name": res["name"], "size": int(res["size"])}
388
+
389
+ def save_inbox_item(self, item_id: str, path: str | None = None) -> str:
390
+ """Download a received file to ``path`` (default: its own name in the current directory)."""
391
+ d = self.download_inbox_item(item_id)
392
+ target = path or os.path.basename(d["name"]) or "file"
393
+ with urllib.request.urlopen(d["url"], timeout=self.timeout) as res, open(target, "wb") as out:
394
+ while True:
395
+ chunk = res.read(1 << 20)
396
+ if not chunk:
397
+ break
398
+ out.write(chunk)
399
+ return target
400
+
401
+ # Recipient feedback (comments, ratings, approvals) on a send.
402
+
403
+ def link_feedback(self, link_id: str) -> dict[str, Any]:
404
+ return self._request("GET", f"/v1/links/{link_id}/feedback")
405
+
406
+ def link_feedback_comments(self, link_id: str, file: str) -> list[dict[str, Any]]:
407
+ q = urllib.parse.urlencode({"file": file})
408
+ return self._request("GET", f"/v1/links/{link_id}/feedback/comments?{q}")["comments"]
409
+
410
+ def export_link_feedback(self, link_id: str) -> dict[str, Any]:
411
+ return self._request("GET", f"/v1/links/{link_id}/feedback/export?format=json")
412
+
413
+ # Notification channels (email, Slack, webhook, Discord, Teams).
414
+
415
+ def list_notification_channels(self) -> dict[str, Any]:
416
+ return self._request("GET", "/v1/notification-channels")
417
+
418
+ def add_notification_channel(self, channel: str, name: str, target: str) -> dict[str, Any]:
419
+ return self._request("POST", "/v1/notification-channels", {"channel": channel, "name": name, "target": target})
420
+
421
+ def update_notification_channel(self, channel_id: str, name: str | None = None, target: str | None = None, active: bool | None = None) -> dict[str, Any]:
422
+ patch = {k: v for k, v in (("name", name), ("target", target), ("active", active)) if v is not None}
423
+ return self._request("PATCH", f"/v1/notification-channels/{channel_id}", patch)
424
+
425
+ def remove_notification_channel(self, channel_id: str) -> None:
426
+ self._request("DELETE", f"/v1/notification-channels/{channel_id}")
427
+
428
+ def test_notification_channel(self, channel_id: str) -> dict[str, Any]:
429
+ return self._request("POST", f"/v1/notification-channels/{channel_id}/test")
430
+
431
+ # Agent tasks: give files to an AI agent (a cloud agent or one on your computer) and follow it.
432
+
433
+ def list_agents(self) -> list[dict[str, Any]]:
434
+ return self._request("GET", "/v1/agent-targets")["targets"]
435
+
436
+ def send_to_agent(self, cloud_agent_id: str, file_ids: Iterable[str], instruction: str | None = None, preset: str | None = None, from_name: str | None = None) -> dict[str, Any]:
437
+ body = {"cloud_agent_id": cloud_agent_id, "file_ids": list(file_ids), "instruction": instruction, "preset": preset, "from_name": from_name}
438
+ return self._request("POST", "/v1/agent-tasks", {k: v for k, v in body.items() if v is not None})["task"]
439
+
440
+ def list_agent_tasks(self, box: str | None = None, agent: str | None = None, state: str | None = None, limit: int | None = None) -> list[dict[str, Any]]:
441
+ q = urllib.parse.urlencode({k: v for k, v in (("box", box), ("agent", agent), ("state", state), ("limit", limit)) if v is not None})
442
+ return self._request("GET", f"/v1/agent-tasks{'?' + q if q else ''}")["tasks"]
443
+
444
+ def get_agent_task(self, task_id: str) -> dict[str, Any]:
445
+ """``{"task": …, "events": […]}``."""
446
+ return self._request("GET", f"/v1/agent-tasks/{task_id}")
447
+
448
+ def answer_agent_task(self, task_id: str, question_id: str, option_id: str | None = None, text: str | None = None) -> dict[str, Any]:
449
+ return self._request("POST", f"/v1/agent-tasks/{task_id}/answer", {"question_id": question_id, "option_id": option_id, "text": text})["task"]
450
+
451
+ def cancel_agent_task(self, task_id: str) -> dict[str, Any]:
452
+ return self._request("POST", f"/v1/agent-tasks/{task_id}/cancel", {})["task"]
453
+
454
+ def retry_agent_task(self, task_id: str) -> dict[str, Any]:
455
+ return self._request("POST", f"/v1/agent-tasks/{task_id}/retry", {})["task"]
456
+
457
+ def wait_for_agent_task(self, task_id: str, timeout: float = 600.0, interval: float = 3.0, until_question: bool = False) -> dict[str, Any]:
458
+ """Poll until the task finishes (or asks a question, with ``until_question``) or ``timeout`` passes."""
459
+ end = time.monotonic() + timeout
460
+ while True:
461
+ task = self.get_agent_task(task_id)["task"]
462
+ if task["state"] in _FINAL_TASK_STATES or (until_question and task["state"] == "input_required"):
463
+ return task
464
+ if time.monotonic() >= end:
465
+ return task
466
+ time.sleep(interval)
467
+
468
+ def credits(self) -> dict[str, Any]:
469
+ """Balance and price list, so an agent can budget BEFORE it spends."""
470
+ res = self._request("GET", "/v1/credits")
471
+ return {"balance": int(res["balance"]), "prices": {k: int(v) for k, v in res["prices"].items()}}
472
+
473
+ def usage(self) -> dict[str, Any]:
474
+ return self._request("GET", "/v1/usage")
475
+
476
+
477
+ def verify_webhook_signature(
478
+ secret: str,
479
+ signature_header: str,
480
+ body: str | bytes,
481
+ tolerance_seconds: int = 300,
482
+ ) -> bool:
483
+ """Verify an inbound webhook.
484
+
485
+ Ships in the SDK so nobody hand-rolls the comparison: the obvious ``==`` on a hex digest is
486
+ timing-attackable, and a signature that omits the timestamp is replayable forever.
487
+ """
488
+ try:
489
+ parts = dict(p.split("=", 1) for p in signature_header.split(","))
490
+ timestamp = int(parts["t"])
491
+ provided = parts["v1"]
492
+ except (ValueError, KeyError):
493
+ return False
494
+
495
+ if abs(int(time.time()) - timestamp) > tolerance_seconds:
496
+ return False
497
+
498
+ payload = body if isinstance(body, bytes) else body.encode()
499
+ expected = hmac.new(secret.encode(), f"{timestamp}.".encode() + payload, hashlib.sha256).hexdigest()
500
+ return hmac.compare_digest(expected, provided)
@@ -0,0 +1,186 @@
1
+ """SendFlo Python SDK tests — stdlib only, against an in-process fake of the API and storage.
2
+
3
+ python -m unittest discover -s tests (from packages/sdk-python)
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import hashlib
9
+ import hmac
10
+ import json
11
+ import os
12
+ import sys
13
+ import tempfile
14
+ import threading
15
+ import time
16
+ import unittest
17
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
18
+
19
+ sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
20
+
21
+ from sendflo import SendFlo, SendFloError, verify_webhook_signature # noqa: E402
22
+
23
+
24
+ class FakeApi:
25
+ """Records every request; answers the routes the SDK calls like the real API does."""
26
+
27
+ def __init__(self) -> None:
28
+ self.calls: list[tuple[str, str, dict | None, dict]] = []
29
+ self.parts: dict[int, bytes] = {}
30
+ self.fail_next: dict[str, int] = {}
31
+ handler = self._handler()
32
+ self.server = ThreadingHTTPServer(("127.0.0.1", 0), handler)
33
+ self.base = f"http://127.0.0.1:{self.server.server_address[1]}"
34
+ threading.Thread(target=self.server.serve_forever, daemon=True).start()
35
+
36
+ def close(self) -> None:
37
+ self.server.shutdown()
38
+
39
+ def _handler(self):
40
+ api = self
41
+
42
+ class H(BaseHTTPRequestHandler):
43
+ def log_message(self, *a): # quiet
44
+ pass
45
+
46
+ def _send(self, status: int, body: object | None = None, headers: dict | None = None) -> None:
47
+ data = b"" if body is None else json.dumps(body).encode()
48
+ self.send_response(status)
49
+ for k, v in (headers or {}).items():
50
+ self.send_header(k, v)
51
+ if body is not None:
52
+ self.send_header("content-type", "application/json")
53
+ self.send_header("content-length", str(len(data)))
54
+ self.end_headers()
55
+ self.wfile.write(data)
56
+
57
+ def _route(self, method: str) -> None:
58
+ length = int(self.headers.get("content-length") or 0)
59
+ raw = self.rfile.read(length) if length else b""
60
+ path = self.path
61
+ if path.startswith("/storage/part/"):
62
+ n = int(path.rsplit("/", 1)[1])
63
+ api.parts[n] = raw
64
+ return self._send(200, None, {"etag": f'"etag-{n}"'})
65
+ if path == "/storage/file":
66
+ # A presigned download: no API key is (or should be) sent here.
67
+ api.calls.append((method, path, None, dict(self.headers)))
68
+ self.send_response(200)
69
+ self.send_header("content-length", "5")
70
+ self.end_headers()
71
+ self.wfile.write(b"a,b,c")
72
+ return
73
+ body = json.loads(raw) if raw else None
74
+ api.calls.append((method, path, body, dict(self.headers)))
75
+ key = f"{method} {path}"
76
+ if api.fail_next.get(key):
77
+ api.fail_next[key] -= 1
78
+ return self._send(503, {"type": "x", "title": "busy", "status": 503, "code": "unavailable"})
79
+ if self.headers.get("authorization") != "Bearer sk_test_unit":
80
+ return self._send(401, {"type": "x", "title": "Bearer token required", "status": 401, "code": "unauthenticated"})
81
+ if key == "POST /v1/transfers":
82
+ size = body["size"]
83
+ n = (size + 4 - 1) // 4
84
+ return self._send(200, {"id": "t1", "upload": {"upload_id": "u", "part_size": 4, "part_urls": [f"{api.base}/storage/part/{i + 1}" for i in range(n)]}})
85
+ if key == "POST /v1/transfers/t1/complete":
86
+ return self._send(200, {"file_id": "f1"})
87
+ if key == "POST /v1/links":
88
+ return self._send(200, {"id": "l1", "slug": "abc", "url": "https://sendflo.app/l/abc"})
89
+ if key == "GET /v1/webhooks":
90
+ return self._send(200, {"data": [{"id": "w1", "url": "https://x.test/h", "events": ["file.flagged"], "disabled": False}]})
91
+ if key == "DELETE /v1/webhooks/w1":
92
+ return self._send(204)
93
+ if key == "POST /v1/inbox-items/i1/download":
94
+ return self._send(200, {"url": f"{api.base}/storage/file", "name": "q3.csv", "size": "5", "ticket": "t"})
95
+ if key.startswith("GET /v1/agent-tasks/k1"):
96
+ return self._send(200, {"task": {"id": "k1", "state": "completed"}, "events": []})
97
+ if key == "GET /v1/credits":
98
+ return self._send(402, {"type": "x", "title": "Out of credits", "status": 402, "code": "insufficient_credits"})
99
+ return self._send(404, {"type": "x", "title": "not found", "status": 404, "code": "not_found"})
100
+
101
+ def do_GET(self):
102
+ self._route("GET")
103
+
104
+ def do_POST(self):
105
+ self._route("POST")
106
+
107
+ def do_PUT(self):
108
+ self._route("PUT")
109
+
110
+ def do_DELETE(self):
111
+ self._route("DELETE")
112
+
113
+ return H
114
+
115
+
116
+ class SdkTest(unittest.TestCase):
117
+ def setUp(self) -> None:
118
+ self.api = FakeApi()
119
+ self.flo = SendFlo(api_key="sk_test_unit", base_url=self.api.base, max_retries=2, timeout=5)
120
+
121
+ def tearDown(self) -> None:
122
+ self.api.close()
123
+
124
+ def test_send_file_uploads_every_part_then_links(self) -> None:
125
+ with tempfile.NamedTemporaryFile("wb", delete=False, suffix=".txt") as f:
126
+ f.write(b"hello world!")
127
+ try:
128
+ r = self.flo.send_file(f.name)
129
+ finally:
130
+ os.unlink(f.name)
131
+ self.assertEqual(r.url, "https://sendflo.app/l/abc")
132
+ self.assertEqual(b"".join(self.api.parts[i] for i in sorted(self.api.parts)), b"hello world!")
133
+ complete = next(c for c in self.api.calls if c[1] == "/v1/transfers/t1/complete")
134
+ self.assertEqual([p["etag"] for p in complete[2]["parts"]], ["etag-1", "etag-2", "etag-3"])
135
+
136
+ def test_errors_carry_the_code_and_402_is_not_retried(self) -> None:
137
+ with self.assertRaises(SendFloError) as e:
138
+ self.flo.credits()
139
+ self.assertEqual(e.exception.code, "insufficient_credits")
140
+ self.assertTrue(e.exception.out_of_credits)
141
+ self.assertEqual(sum(1 for c in self.api.calls if c[1] == "/v1/credits"), 1)
142
+
143
+ def test_a_503_on_a_read_is_retried(self) -> None:
144
+ self.api.fail_next["GET /v1/webhooks"] = 1
145
+ hooks = self.flo.list_webhooks()
146
+ self.assertEqual(hooks[0]["id"], "w1")
147
+ self.assertEqual(sum(1 for c in self.api.calls if c[1] == "/v1/webhooks"), 2)
148
+
149
+ def test_delete_webhook_and_inbox_download(self) -> None:
150
+ self.flo.delete_webhook("w1")
151
+ self.assertIn(("DELETE", "/v1/webhooks/w1"), [(c[0], c[1]) for c in self.api.calls])
152
+ d = self.flo.download_inbox_item("i1")
153
+ self.assertEqual(d["size"], 5)
154
+ with tempfile.TemporaryDirectory() as tmp:
155
+ out = self.flo.save_inbox_item("i1", os.path.join(tmp, "q3.csv"))
156
+ with open(out, "rb") as fh:
157
+ self.assertEqual(fh.read(), b"a,b,c")
158
+ storage = [c for c in self.api.calls if c[1] == "/storage/file"]
159
+ self.assertTrue(storage and all("authorization" not in {k.lower() for k in c[3]} for c in storage), "the API key must never go to storage")
160
+
161
+ def test_wait_for_agent_task_returns_a_final_state(self) -> None:
162
+ task = self.flo.wait_for_agent_task("k1", timeout=5, interval=0.01)
163
+ self.assertEqual(task["state"], "completed")
164
+
165
+ def test_api_key_is_required(self) -> None:
166
+ old = os.environ.pop("SENDFLO_API_KEY", None)
167
+ try:
168
+ with self.assertRaises(ValueError):
169
+ SendFlo()
170
+ finally:
171
+ if old is not None:
172
+ os.environ["SENDFLO_API_KEY"] = old
173
+
174
+
175
+ class WebhookSignatureTest(unittest.TestCase):
176
+ def test_valid_old_and_tampered(self) -> None:
177
+ secret, body, t = "whsec_x", '{"a":1}', int(time.time())
178
+ sig = hmac.new(secret.encode(), f"{t}.{body}".encode(), hashlib.sha256).hexdigest()
179
+ self.assertTrue(verify_webhook_signature(secret, f"t={t},v1={sig}", body))
180
+ self.assertFalse(verify_webhook_signature(secret, f"t={t - 3600},v1={sig}", body))
181
+ self.assertFalse(verify_webhook_signature(secret, f"t={t},v1={sig[:-1]}0", body))
182
+ self.assertFalse(verify_webhook_signature(secret, "garbage", body))
183
+
184
+
185
+ if __name__ == "__main__":
186
+ unittest.main()