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.
- sendflo-0.1.0/.gitignore +50 -0
- sendflo-0.1.0/CHANGELOG.md +8 -0
- sendflo-0.1.0/LICENSE +21 -0
- sendflo-0.1.0/PKG-INFO +84 -0
- sendflo-0.1.0/README.md +65 -0
- sendflo-0.1.0/pyproject.toml +34 -0
- sendflo-0.1.0/sendflo/__init__.py +500 -0
- sendflo-0.1.0/tests/test_sdk.py +186 -0
sendflo-0.1.0/.gitignore
ADDED
|
@@ -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
|
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.
|
sendflo-0.1.0/README.md
ADDED
|
@@ -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()
|