flowflex 0.1.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.
- flowflex/__init__.py +21 -0
- flowflex/client.py +313 -0
- flowflex/errors.py +34 -0
- flowflex/file.py +138 -0
- flowflex-0.1.0.dist-info/METADATA +248 -0
- flowflex-0.1.0.dist-info/RECORD +7 -0
- flowflex-0.1.0.dist-info/WHEEL +4 -0
flowflex/__init__.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Official FlowFlex SDK for Python.
|
|
2
|
+
|
|
3
|
+
Fire custom-integration events and attach private files without handling
|
|
4
|
+
presigned URLs yourself.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from .client import FlowFlex, SendEventResult
|
|
8
|
+
from .errors import FlowFlexConfigError, FlowFlexError, FlowFlexUploadError
|
|
9
|
+
from .file import FileRef, file
|
|
10
|
+
|
|
11
|
+
__version__ = "0.1.0"
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"FlowFlex",
|
|
15
|
+
"SendEventResult",
|
|
16
|
+
"FileRef",
|
|
17
|
+
"file",
|
|
18
|
+
"FlowFlexError",
|
|
19
|
+
"FlowFlexConfigError",
|
|
20
|
+
"FlowFlexUploadError",
|
|
21
|
+
]
|
flowflex/client.py
ADDED
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
"""FlowFlex client — fire custom-integration events with optional private files."""
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import re
|
|
5
|
+
import uuid as _uuid
|
|
6
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
7
|
+
from typing import Any, Callable, Dict, List, Optional, Tuple
|
|
8
|
+
from urllib.parse import quote, urlparse
|
|
9
|
+
|
|
10
|
+
import requests
|
|
11
|
+
|
|
12
|
+
from .errors import FlowFlexConfigError, FlowFlexError, FlowFlexUploadError
|
|
13
|
+
from .file import FileRef, FileSource, file as make_file
|
|
14
|
+
|
|
15
|
+
# 25 MB — mirrors the backend's asset size limit.
|
|
16
|
+
DEFAULT_MAX_FILE_BYTES = 25 * 1024 * 1024
|
|
17
|
+
|
|
18
|
+
_CONTROL_CHARS = re.compile(r"[\r\n\x00-\x1f\x7f]")
|
|
19
|
+
|
|
20
|
+
_LOOPBACK_HOSTS = {"localhost", "127.0.0.1", "[::1]", "::1"}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _assert_safe_header_value(label: str, value: str) -> None:
|
|
24
|
+
"""Reject values that would break out of an HTTP header (CRLF / control
|
|
25
|
+
chars) — prevents header-injection via the event name or idempotency key."""
|
|
26
|
+
if _CONTROL_CHARS.search(value):
|
|
27
|
+
raise FlowFlexConfigError(f"{label} contains invalid control characters")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class SendEventResult:
|
|
31
|
+
"""Result returned by ``send_event``."""
|
|
32
|
+
|
|
33
|
+
def __init__(self, response: Any, uploaded_assets: Dict[str, str]) -> None:
|
|
34
|
+
#: Raw response body from the events endpoint.
|
|
35
|
+
self.response = response
|
|
36
|
+
#: Map of payload paths -> assetId, for every file that was uploaded.
|
|
37
|
+
self.uploaded_assets = uploaded_assets
|
|
38
|
+
|
|
39
|
+
def __repr__(self) -> str:
|
|
40
|
+
return (
|
|
41
|
+
f"SendEventResult(response={self.response!r}, "
|
|
42
|
+
f"uploaded_assets={self.uploaded_assets!r})"
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class FlowFlex:
|
|
47
|
+
"""FlowFlex custom-integration client.
|
|
48
|
+
|
|
49
|
+
Example::
|
|
50
|
+
|
|
51
|
+
from flowflex import FlowFlex
|
|
52
|
+
|
|
53
|
+
ff = FlowFlex(
|
|
54
|
+
api_key="cik_xxx",
|
|
55
|
+
api_secret="yyy",
|
|
56
|
+
integration_code="ic_abc123",
|
|
57
|
+
base_url="https://api.flowflex.ai",
|
|
58
|
+
)
|
|
59
|
+
ff.send_event("order.placed", payload={"name": "Ada"})
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
def __init__(
|
|
63
|
+
self,
|
|
64
|
+
*,
|
|
65
|
+
api_key: str,
|
|
66
|
+
api_secret: str,
|
|
67
|
+
integration_code: str,
|
|
68
|
+
base_url: str,
|
|
69
|
+
timeout_seconds: float = 30.0,
|
|
70
|
+
max_file_bytes: int = DEFAULT_MAX_FILE_BYTES,
|
|
71
|
+
session: Optional[requests.Session] = None,
|
|
72
|
+
) -> None:
|
|
73
|
+
if not api_key:
|
|
74
|
+
raise FlowFlexConfigError("api_key is required")
|
|
75
|
+
if not api_secret:
|
|
76
|
+
raise FlowFlexConfigError("api_secret is required")
|
|
77
|
+
if not integration_code:
|
|
78
|
+
raise FlowFlexConfigError("integration_code is required")
|
|
79
|
+
if not base_url:
|
|
80
|
+
raise FlowFlexConfigError("base_url is required")
|
|
81
|
+
|
|
82
|
+
# Validate base_url and enforce HTTPS off-loopback so Basic-auth
|
|
83
|
+
# credentials are never sent in cleartext.
|
|
84
|
+
stripped = re.sub(r"/api/?$", "", base_url).rstrip("/")
|
|
85
|
+
parsed = urlparse(stripped)
|
|
86
|
+
if parsed.scheme not in ("http", "https") or not parsed.hostname:
|
|
87
|
+
raise FlowFlexConfigError(f'base_url is not a valid URL: "{base_url}"')
|
|
88
|
+
if parsed.scheme != "https" and parsed.hostname not in _LOOPBACK_HOSTS:
|
|
89
|
+
raise FlowFlexConfigError(
|
|
90
|
+
f'base_url must use https (got "{parsed.scheme}://{parsed.hostname}"). '
|
|
91
|
+
"Basic-auth credentials would otherwise be sent in cleartext. "
|
|
92
|
+
"Plain http is only allowed for localhost."
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
self._base_url = stripped
|
|
96
|
+
self._integration_code = integration_code
|
|
97
|
+
self._timeout = timeout_seconds
|
|
98
|
+
self._max_file_bytes = max_file_bytes
|
|
99
|
+
self._session = session or requests.Session()
|
|
100
|
+
token = base64.b64encode(f"{api_key}:{api_secret}".encode()).decode()
|
|
101
|
+
self._auth_header = f"Basic {token}"
|
|
102
|
+
|
|
103
|
+
# ------------------------------------------------------------------ files
|
|
104
|
+
|
|
105
|
+
def file(
|
|
106
|
+
self,
|
|
107
|
+
source: FileSource,
|
|
108
|
+
*,
|
|
109
|
+
filename: Optional[str] = None,
|
|
110
|
+
mime: Optional[str] = None,
|
|
111
|
+
size: Optional[int] = None,
|
|
112
|
+
) -> FileRef:
|
|
113
|
+
"""Convenience re-export so callers can do ``ff.file(...)``."""
|
|
114
|
+
return make_file(source, filename=filename, mime=mime, size=size)
|
|
115
|
+
|
|
116
|
+
# ------------------------------------------------------------------- http
|
|
117
|
+
|
|
118
|
+
def _request(self, method: str, url: str, **kwargs: Any) -> Any:
|
|
119
|
+
"""HTTP wrapper with timeout + JSON parsing + error normalization."""
|
|
120
|
+
try:
|
|
121
|
+
res = self._session.request(method, url, timeout=self._timeout, **kwargs)
|
|
122
|
+
except requests.Timeout as err:
|
|
123
|
+
raise FlowFlexError(
|
|
124
|
+
f"Request to {url} timed out after {self._timeout}s"
|
|
125
|
+
) from err
|
|
126
|
+
except requests.RequestException as err:
|
|
127
|
+
raise FlowFlexError(f"Network error calling {url}: {err}") from err
|
|
128
|
+
|
|
129
|
+
body: Any = res.text
|
|
130
|
+
if body and "application/json" in (res.headers.get("content-type") or ""):
|
|
131
|
+
try:
|
|
132
|
+
body = res.json()
|
|
133
|
+
except ValueError:
|
|
134
|
+
pass # leave as text
|
|
135
|
+
|
|
136
|
+
if not res.ok:
|
|
137
|
+
message = None
|
|
138
|
+
if isinstance(body, dict):
|
|
139
|
+
message = body.get("error") or body.get("message")
|
|
140
|
+
message = message or f"Request to {url} failed with status {res.status_code}"
|
|
141
|
+
code = body.get("code") or body.get("name") if isinstance(body, dict) else None
|
|
142
|
+
raise FlowFlexError(
|
|
143
|
+
str(message), status=res.status_code, code=code, details=body
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
return body
|
|
147
|
+
|
|
148
|
+
# ------------------------------------------------------------------ assets
|
|
149
|
+
|
|
150
|
+
def create_upload_url(
|
|
151
|
+
self, *, filename: str, mime: str, size: Optional[int] = None
|
|
152
|
+
) -> Dict[str, Any]:
|
|
153
|
+
"""Request a presigned upload URL.
|
|
154
|
+
|
|
155
|
+
Most callers don't need this directly — use ``file()`` inside an event
|
|
156
|
+
payload and let ``send_event`` handle uploads.
|
|
157
|
+
"""
|
|
158
|
+
payload: Dict[str, Any] = {"filename": filename, "mime": mime}
|
|
159
|
+
if size is not None:
|
|
160
|
+
payload["size"] = size
|
|
161
|
+
return self._request(
|
|
162
|
+
"POST",
|
|
163
|
+
f"{self._base_url}/v1/assets/upload-url",
|
|
164
|
+
headers={"Authorization": self._auth_header},
|
|
165
|
+
json=payload,
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
def upload_file(self, ref: FileRef) -> str:
|
|
169
|
+
"""Upload one FileRef and return its assetId."""
|
|
170
|
+
data = ref.read_bytes()
|
|
171
|
+
# Fail fast on oversized files instead of uploading then being rejected.
|
|
172
|
+
if len(data) > self._max_file_bytes:
|
|
173
|
+
raise FlowFlexUploadError(
|
|
174
|
+
f'"{ref.filename}" is {len(data)} bytes, '
|
|
175
|
+
f"over the {self._max_file_bytes}-byte limit"
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
info = self.create_upload_url(
|
|
179
|
+
filename=ref.filename, mime=ref.mime, size=ref.size
|
|
180
|
+
)
|
|
181
|
+
asset_id, upload_url = info["assetId"], info["uploadUrl"]
|
|
182
|
+
|
|
183
|
+
# PUT the raw bytes straight to storage. No auth header — the presigned
|
|
184
|
+
# URL carries its own token, and an extra Authorization breaks it.
|
|
185
|
+
try:
|
|
186
|
+
res = requests.put(
|
|
187
|
+
upload_url,
|
|
188
|
+
data=data,
|
|
189
|
+
headers={"Content-Type": ref.mime},
|
|
190
|
+
timeout=self._timeout,
|
|
191
|
+
)
|
|
192
|
+
except requests.RequestException as err:
|
|
193
|
+
raise FlowFlexUploadError(
|
|
194
|
+
f'Failed to upload "{ref.filename}": {err}'
|
|
195
|
+
) from err
|
|
196
|
+
|
|
197
|
+
if not res.ok:
|
|
198
|
+
raise FlowFlexUploadError(
|
|
199
|
+
f'Storage rejected upload of "{ref.filename}" (status {res.status_code})',
|
|
200
|
+
status=res.status_code,
|
|
201
|
+
details=res.text,
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
return asset_id
|
|
205
|
+
|
|
206
|
+
# ----------------------------------------------------------------- events
|
|
207
|
+
|
|
208
|
+
def _clone_and_collect(
|
|
209
|
+
self,
|
|
210
|
+
value: Any,
|
|
211
|
+
path: str,
|
|
212
|
+
refs: List[Tuple[Any, Any, FileRef, str]],
|
|
213
|
+
) -> Any:
|
|
214
|
+
"""Deep-clone a payload, collecting every FileRef anywhere inside it
|
|
215
|
+
(nested dicts, lists, mixed) alongside its parent container + key so we
|
|
216
|
+
can patch the assetId back in after upload."""
|
|
217
|
+
if isinstance(value, FileRef):
|
|
218
|
+
return value # bare FileRef — caller decides
|
|
219
|
+
|
|
220
|
+
if isinstance(value, list):
|
|
221
|
+
out_list: List[Any] = [None] * len(value)
|
|
222
|
+
for i, v in enumerate(value):
|
|
223
|
+
p = f"{path}[{i}]"
|
|
224
|
+
if isinstance(v, FileRef):
|
|
225
|
+
refs.append((out_list, i, v, p))
|
|
226
|
+
else:
|
|
227
|
+
out_list[i] = self._clone_and_collect(v, p, refs)
|
|
228
|
+
return out_list
|
|
229
|
+
|
|
230
|
+
if isinstance(value, dict):
|
|
231
|
+
out: Dict[str, Any] = {}
|
|
232
|
+
for k, v in value.items():
|
|
233
|
+
p = f"{path}.{k}" if path else str(k)
|
|
234
|
+
if isinstance(v, FileRef):
|
|
235
|
+
refs.append((out, k, v, p))
|
|
236
|
+
else:
|
|
237
|
+
out[k] = self._clone_and_collect(v, p, refs)
|
|
238
|
+
return out
|
|
239
|
+
|
|
240
|
+
return value
|
|
241
|
+
|
|
242
|
+
def _resolve_files(self, payload: Any) -> Tuple[Any, Dict[str, str]]:
|
|
243
|
+
"""Upload every FileRef in a payload and return a deep copy with each
|
|
244
|
+
replaced by its assetId string, plus a map of path -> assetId.
|
|
245
|
+
|
|
246
|
+
Uploads run in parallel; the SAME FileRef instance reused in multiple
|
|
247
|
+
places is uploaded only once and its assetId reused everywhere.
|
|
248
|
+
"""
|
|
249
|
+
refs: List[Tuple[Any, Any, FileRef, str]] = []
|
|
250
|
+
resolved = self._clone_and_collect(payload, "", refs)
|
|
251
|
+
|
|
252
|
+
# Dedupe by FileRef identity so reusing one file() uploads once.
|
|
253
|
+
unique_refs = list({id(r[2]): r[2] for r in refs}.values())
|
|
254
|
+
id_by_ref: Dict[int, str] = {}
|
|
255
|
+
if unique_refs:
|
|
256
|
+
with ThreadPoolExecutor(max_workers=min(8, len(unique_refs))) as pool:
|
|
257
|
+
for ref, asset_id in zip(
|
|
258
|
+
unique_refs, pool.map(self.upload_file, unique_refs)
|
|
259
|
+
):
|
|
260
|
+
id_by_ref[id(ref)] = asset_id
|
|
261
|
+
|
|
262
|
+
uploaded: Dict[str, str] = {}
|
|
263
|
+
for container, key, ref, path in refs:
|
|
264
|
+
asset_id = id_by_ref[id(ref)]
|
|
265
|
+
container[key] = asset_id
|
|
266
|
+
uploaded[path] = asset_id
|
|
267
|
+
|
|
268
|
+
return resolved, uploaded
|
|
269
|
+
|
|
270
|
+
def send_event(
|
|
271
|
+
self,
|
|
272
|
+
event: str,
|
|
273
|
+
*,
|
|
274
|
+
payload: Optional[Dict[str, Any]] = None,
|
|
275
|
+
idempotency_key: Optional[str] = None,
|
|
276
|
+
) -> SendEventResult:
|
|
277
|
+
"""Fire a custom-integration event into FlowFlex.
|
|
278
|
+
|
|
279
|
+
Any ``file()`` in the payload is uploaded first and replaced with its
|
|
280
|
+
assetId, so a flow's media node can reference it as e.g.
|
|
281
|
+
``{{trigger.assetId}}``.
|
|
282
|
+
|
|
283
|
+
Example::
|
|
284
|
+
|
|
285
|
+
ff.send_event("invoice.created", payload={
|
|
286
|
+
"assetId": ff.file("./invoice.pdf"),
|
|
287
|
+
"name": "Ada",
|
|
288
|
+
})
|
|
289
|
+
"""
|
|
290
|
+
if not event:
|
|
291
|
+
raise FlowFlexError("event name is required")
|
|
292
|
+
|
|
293
|
+
# These flow into HTTP headers — reject control characters (CRLF) to
|
|
294
|
+
# prevent header injection.
|
|
295
|
+
_assert_safe_header_value("event", event)
|
|
296
|
+
idem_key = idempotency_key or str(_uuid.uuid4())
|
|
297
|
+
_assert_safe_header_value("idempotency_key", idem_key)
|
|
298
|
+
|
|
299
|
+
resolved, uploaded = self._resolve_files(payload or {})
|
|
300
|
+
|
|
301
|
+
response = self._request(
|
|
302
|
+
"POST",
|
|
303
|
+
# Encode the code so it can't break out of the URL path.
|
|
304
|
+
f"{self._base_url}/v1/events/{quote(self._integration_code, safe='')}",
|
|
305
|
+
headers={
|
|
306
|
+
"Authorization": self._auth_header,
|
|
307
|
+
"x-event": event,
|
|
308
|
+
"x-idempotency-key": idem_key,
|
|
309
|
+
},
|
|
310
|
+
json=resolved,
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
return SendEventResult(response, uploaded)
|
flowflex/errors.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Error types raised by the FlowFlex SDK."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, Optional
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class FlowFlexError(Exception):
|
|
7
|
+
"""Base error for every failure raised by the SDK.
|
|
8
|
+
|
|
9
|
+
Carries the HTTP status (when the failure came from an API call) and the
|
|
10
|
+
machine-readable code returned by the backend (e.g. "MIME_NOT_ALLOWED",
|
|
11
|
+
"ASSET_FILE_MISSING").
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
def __init__(
|
|
15
|
+
self,
|
|
16
|
+
message: str,
|
|
17
|
+
*,
|
|
18
|
+
status: Optional[int] = None,
|
|
19
|
+
code: Optional[str] = None,
|
|
20
|
+
details: Any = None,
|
|
21
|
+
) -> None:
|
|
22
|
+
super().__init__(message)
|
|
23
|
+
self.message = message
|
|
24
|
+
self.status = status
|
|
25
|
+
self.code = code
|
|
26
|
+
self.details = details
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class FlowFlexConfigError(FlowFlexError):
|
|
30
|
+
"""Raised when the SDK is constructed with missing/invalid configuration."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class FlowFlexUploadError(FlowFlexError):
|
|
34
|
+
"""Raised when a file attachment cannot be uploaded."""
|
flowflex/file.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""Lazy file references for event attachments."""
|
|
2
|
+
|
|
3
|
+
import io
|
|
4
|
+
import os
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import BinaryIO, Optional, Union
|
|
7
|
+
|
|
8
|
+
from .errors import FlowFlexUploadError
|
|
9
|
+
|
|
10
|
+
FileSource = Union[str, Path, bytes, bytearray, BinaryIO]
|
|
11
|
+
|
|
12
|
+
# Maps common extensions -> MIME types accepted by the FlowFlex assets API.
|
|
13
|
+
EXT_TO_MIME = {
|
|
14
|
+
"pdf": "application/pdf",
|
|
15
|
+
"doc": "application/msword",
|
|
16
|
+
"docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
17
|
+
"xls": "application/vnd.ms-excel",
|
|
18
|
+
"xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
19
|
+
"ppt": "application/vnd.ms-powerpoint",
|
|
20
|
+
"pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
|
21
|
+
"txt": "text/plain",
|
|
22
|
+
"jpg": "image/jpeg",
|
|
23
|
+
"jpeg": "image/jpeg",
|
|
24
|
+
"png": "image/png",
|
|
25
|
+
"mp4": "video/mp4",
|
|
26
|
+
"3gp": "video/3gpp",
|
|
27
|
+
"aac": "audio/aac",
|
|
28
|
+
"amr": "audio/amr",
|
|
29
|
+
"mp3": "audio/mpeg",
|
|
30
|
+
"ogg": "audio/ogg",
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _infer_mime_from_name(name: str) -> Optional[str]:
|
|
35
|
+
ext = name.rsplit(".", 1)[-1].lower() if "." in name else None
|
|
36
|
+
return EXT_TO_MIME.get(ext) if ext else None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class FileRef:
|
|
40
|
+
"""A lazy reference to a file the caller wants to attach to an event.
|
|
41
|
+
|
|
42
|
+
The bytes are not read until the event is sent. Create one with
|
|
43
|
+
``FlowFlex.file()`` or the standalone ``file()`` helper.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
def __init__(
|
|
47
|
+
self,
|
|
48
|
+
source: FileSource,
|
|
49
|
+
*,
|
|
50
|
+
filename: Optional[str] = None,
|
|
51
|
+
mime: Optional[str] = None,
|
|
52
|
+
size: Optional[int] = None,
|
|
53
|
+
) -> None:
|
|
54
|
+
self.source = source
|
|
55
|
+
self._filename = filename
|
|
56
|
+
self._mime = mime
|
|
57
|
+
self._size = size
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def filename(self) -> str:
|
|
61
|
+
"""Resolve the filename, inferring from a path source when possible."""
|
|
62
|
+
if self._filename:
|
|
63
|
+
return self._filename
|
|
64
|
+
if isinstance(self.source, (str, Path)):
|
|
65
|
+
return os.path.basename(str(self.source))
|
|
66
|
+
name = getattr(self.source, "name", None)
|
|
67
|
+
if isinstance(name, str) and name and not name.startswith("<"):
|
|
68
|
+
return os.path.basename(name)
|
|
69
|
+
raise FlowFlexUploadError(
|
|
70
|
+
"Cannot determine filename for attachment — pass filename= to file()"
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def mime(self) -> str:
|
|
75
|
+
"""Resolve the MIME type, inferring from the filename when possible."""
|
|
76
|
+
if self._mime:
|
|
77
|
+
return self._mime
|
|
78
|
+
inferred = _infer_mime_from_name(self.filename)
|
|
79
|
+
if inferred:
|
|
80
|
+
return inferred
|
|
81
|
+
raise FlowFlexUploadError(
|
|
82
|
+
f'Cannot determine MIME type for "{self.filename}" — pass mime= to file()'
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def size(self) -> Optional[int]:
|
|
87
|
+
if self._size is not None:
|
|
88
|
+
return self._size
|
|
89
|
+
if isinstance(self.source, (bytes, bytearray)):
|
|
90
|
+
return len(self.source)
|
|
91
|
+
return None
|
|
92
|
+
|
|
93
|
+
def read_bytes(self) -> bytes:
|
|
94
|
+
"""Read the file bytes, ready to PUT to storage."""
|
|
95
|
+
src = self.source
|
|
96
|
+
if isinstance(src, (str, Path)):
|
|
97
|
+
try:
|
|
98
|
+
with open(src, "rb") as fh:
|
|
99
|
+
return fh.read()
|
|
100
|
+
except OSError as err:
|
|
101
|
+
raise FlowFlexUploadError(
|
|
102
|
+
f'Failed to read "{src}": {err}'
|
|
103
|
+
) from err
|
|
104
|
+
if isinstance(src, (bytes, bytearray)):
|
|
105
|
+
return bytes(src)
|
|
106
|
+
if hasattr(src, "read"):
|
|
107
|
+
data = src.read()
|
|
108
|
+
if isinstance(data, str):
|
|
109
|
+
data = data.encode()
|
|
110
|
+
if hasattr(src, "seek"):
|
|
111
|
+
try:
|
|
112
|
+
src.seek(0)
|
|
113
|
+
except (OSError, io.UnsupportedOperation):
|
|
114
|
+
pass
|
|
115
|
+
return data
|
|
116
|
+
raise FlowFlexUploadError("Unsupported file source")
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def file(
|
|
120
|
+
source: FileSource,
|
|
121
|
+
*,
|
|
122
|
+
filename: Optional[str] = None,
|
|
123
|
+
mime: Optional[str] = None,
|
|
124
|
+
size: Optional[int] = None,
|
|
125
|
+
) -> FileRef:
|
|
126
|
+
"""Wrap a file so it can be attached to an event payload.
|
|
127
|
+
|
|
128
|
+
The SDK uploads it via a presigned URL when the event is sent and swaps it
|
|
129
|
+
for its ``assetId``.
|
|
130
|
+
|
|
131
|
+
Example::
|
|
132
|
+
|
|
133
|
+
ff.send_event("invoice.created", payload={
|
|
134
|
+
"assetId": file("./invoice.pdf"),
|
|
135
|
+
"customer_id": "cust_123",
|
|
136
|
+
})
|
|
137
|
+
"""
|
|
138
|
+
return FileRef(source, filename=filename, mime=mime, size=size)
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: flowflex
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Official FlowFlex SDK — fire custom-integration events and attach private files without handling presigned URLs yourself.
|
|
5
|
+
Project-URL: Homepage, https://flowflex.ai
|
|
6
|
+
License: UNLICENSED
|
|
7
|
+
Keywords: automation,flowflex,flows,sdk,whatsapp
|
|
8
|
+
Classifier: Operating System :: OS Independent
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Requires-Python: >=3.9
|
|
11
|
+
Requires-Dist: requests>=2.28
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
|
|
14
|
+
# flowflex
|
|
15
|
+
|
|
16
|
+
Official Python SDK for firing **FlowFlex custom-integration events** and
|
|
17
|
+
attaching **private files** to them — without ever dealing with presigned URLs,
|
|
18
|
+
Basic-auth headers, or the multi-step upload dance yourself.
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
pip install flowflex
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Requires Python 3.9+.
|
|
25
|
+
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
## Quick start
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
from flowflex import FlowFlex
|
|
32
|
+
|
|
33
|
+
ff = FlowFlex(
|
|
34
|
+
api_key="cik_xxx", # from your custom integration
|
|
35
|
+
api_secret="yyy",
|
|
36
|
+
integration_code="ic_abc123", # the code in your event URL: /v1/events/<code>
|
|
37
|
+
base_url="https://api.flowflex.ai",
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
# Plain event, no file
|
|
41
|
+
ff.send_event("order.placed", payload={"name": "Ada", "order_id": "ord_42"})
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
In your flow builder, the values land under `trigger` — e.g. `{{trigger.name}}`,
|
|
45
|
+
`{{trigger.order_id}}`.
|
|
46
|
+
|
|
47
|
+
---
|
|
48
|
+
|
|
49
|
+
## Attaching a private file
|
|
50
|
+
|
|
51
|
+
Wrap any file in `ff.file(...)` and drop it into the payload. The SDK uploads it
|
|
52
|
+
through a presigned URL and replaces it with its opaque `assetId` before the
|
|
53
|
+
event is sent. **The file bytes go straight to storage — they never pass through
|
|
54
|
+
the FlowFlex app server.**
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
ff.send_event("invoice.created", payload={
|
|
58
|
+
"assetId": ff.file("./invoice.pdf"), # ← becomes "asset_..." on the wire
|
|
59
|
+
"customer_id": "cust_123",
|
|
60
|
+
})
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Then in your flow's **Media Message** node:
|
|
64
|
+
|
|
65
|
+
1. Set **File source** → `Private file (assetId)`
|
|
66
|
+
2. Set the **Asset ID** field → `{{trigger.assetId}}`
|
|
67
|
+
|
|
68
|
+
The key you use in the payload is the key you reference in the flow. If you send
|
|
69
|
+
`{"invoice": ff.file(...)}`, reference it as `{{trigger.invoice}}`.
|
|
70
|
+
|
|
71
|
+
### Multiple files
|
|
72
|
+
|
|
73
|
+
A flow can have as many media nodes as you like — put a `file()` anywhere in the
|
|
74
|
+
payload (top-level, nested, or in lists) and the SDK uploads them **all in
|
|
75
|
+
parallel** and swaps each for its `assetId`. The shape is entirely up to you;
|
|
76
|
+
reference each one by its path in the flow builder.
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
ff.send_event("order.shipped", payload={
|
|
80
|
+
"invoice": ff.file("./invoice.pdf"), # {{trigger.invoice}}
|
|
81
|
+
"label": ff.file("./label.png"), # {{trigger.label}}
|
|
82
|
+
"gallery": [ff.file("./a.jpg"), ff.file("./b.jpg")], # {{trigger.gallery[0]}}, {{trigger.gallery[1]}}
|
|
83
|
+
"order": {"receipt": ff.file("./receipt.pdf")}, # {{trigger.order.receipt}}
|
|
84
|
+
"note": "non-file values pass through untouched",
|
|
85
|
+
})
|
|
86
|
+
# → {"invoice": "asset_a", "label": "asset_b",
|
|
87
|
+
# "gallery": ["asset_c", "asset_d"],
|
|
88
|
+
# "order": {"receipt": "asset_e"}, "note": "..."}
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
`result.uploaded_assets` maps each payload path to its assetId, e.g.
|
|
92
|
+
`{"invoice": "asset_a", "gallery[0]": "asset_c", "order.receipt": "asset_e"}`.
|
|
93
|
+
|
|
94
|
+
**Reusing one file across nodes:** if you pass the *same* `file()` instance in
|
|
95
|
+
multiple places, it's uploaded only once and the same `assetId` is used
|
|
96
|
+
everywhere:
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
banner = ff.file("./banner.png")
|
|
100
|
+
ff.send_event("promo.sent", payload={
|
|
101
|
+
"header": banner, "footer": banner, # one upload, same assetId in both
|
|
102
|
+
})
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
---
|
|
106
|
+
|
|
107
|
+
## Supplying file bytes other ways
|
|
108
|
+
|
|
109
|
+
`file()` accepts a path (`str` or `pathlib.Path`), raw `bytes`, or any binary
|
|
110
|
+
file-like object. When the type can't be inferred, pass `filename` and `mime`:
|
|
111
|
+
|
|
112
|
+
```python
|
|
113
|
+
# Raw bytes
|
|
114
|
+
ff.file(pdf_bytes, filename="invoice.pdf", mime="application/pdf")
|
|
115
|
+
|
|
116
|
+
# An open file object
|
|
117
|
+
with open("invoice.pdf", "rb") as fh:
|
|
118
|
+
ff.send_event("invoice.created", payload={"assetId": ff.file(fh)})
|
|
119
|
+
|
|
120
|
+
# Override the stored filename
|
|
121
|
+
ff.file("./tmp-7f3a.pdf", filename="Invoice-2026.pdf")
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
**Allowed types:** PDF, DOC(X), XLS(X), PPT(X), TXT, JPEG, PNG, MP4, 3GPP, AAC,
|
|
125
|
+
AMR, MP3, OGG. **Max size:** 25 MB.
|
|
126
|
+
|
|
127
|
+
---
|
|
128
|
+
|
|
129
|
+
## API
|
|
130
|
+
|
|
131
|
+
### `FlowFlex(...)`
|
|
132
|
+
|
|
133
|
+
| Option | Type | Required | Notes |
|
|
134
|
+
| ------------------ | ------------------ | -------- | ------------------------------------------------------------------------------ |
|
|
135
|
+
| `api_key` | str | yes | Custom-integration key (`cik_…`). |
|
|
136
|
+
| `api_secret` | str | yes | Custom-integration secret. |
|
|
137
|
+
| `integration_code` | str | yes | The `<code>` in `/v1/events/<code>`. |
|
|
138
|
+
| `base_url` | str | yes | FlowFlex host. Must be `https` (except `localhost`). Trailing `/api` stripped. |
|
|
139
|
+
| `timeout_seconds` | float | no | Per-request timeout. Default `30.0`. |
|
|
140
|
+
| `max_file_bytes` | int | no | Client-side size cap. Default `26214400` (25 MB). |
|
|
141
|
+
| `session` | requests.Session | no | Custom session for connection pooling / proxies. |
|
|
142
|
+
|
|
143
|
+
### `ff.send_event(event, payload=None, idempotency_key=None)`
|
|
144
|
+
|
|
145
|
+
Uploads any `file()` in `payload`, then POSTs the event. Returns a
|
|
146
|
+
`SendEventResult`:
|
|
147
|
+
|
|
148
|
+
```python
|
|
149
|
+
result.response # raw body from the events endpoint
|
|
150
|
+
result.uploaded_assets # dict: payload path → assetId
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
An `idempotency_key` is auto-generated (UUID) if you don't pass one — safe to
|
|
154
|
+
retry the same call.
|
|
155
|
+
|
|
156
|
+
### `ff.file(source, filename=None, mime=None, size=None)`
|
|
157
|
+
|
|
158
|
+
Returns a lazy `FileRef`. Bytes are read only when the event is sent.
|
|
159
|
+
|
|
160
|
+
### Lower-level helpers
|
|
161
|
+
|
|
162
|
+
```python
|
|
163
|
+
info = ff.create_upload_url(filename="x.pdf", mime="application/pdf")
|
|
164
|
+
# → {"assetId": ..., "uploadUrl": ..., "token": ...}
|
|
165
|
+
|
|
166
|
+
asset_id = ff.upload_file(ff.file("./x.pdf")) # upload, get assetId
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
---
|
|
170
|
+
|
|
171
|
+
## Errors
|
|
172
|
+
|
|
173
|
+
All errors extend `FlowFlexError` (`.message`, `.status`, `.code`, `.details`):
|
|
174
|
+
|
|
175
|
+
- `FlowFlexConfigError` — bad/missing constructor options.
|
|
176
|
+
- `FlowFlexUploadError` — a file couldn't be read or storage rejected it.
|
|
177
|
+
- `FlowFlexError` — API or network failure (`.code` is the backend error code,
|
|
178
|
+
e.g. `MIME_NOT_ALLOWED`, `ASSET_FILE_MISSING`).
|
|
179
|
+
|
|
180
|
+
```python
|
|
181
|
+
from flowflex import FlowFlex, FlowFlexError
|
|
182
|
+
|
|
183
|
+
try:
|
|
184
|
+
ff.send_event("invoice.created", payload={"assetId": ff.file("./big.pdf")})
|
|
185
|
+
except FlowFlexError as err:
|
|
186
|
+
print(err.code, err.status, err.message)
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
---
|
|
190
|
+
|
|
191
|
+
## File lifetime & storage cleanup
|
|
192
|
+
|
|
193
|
+
> **Important — read before using file attachments in production.**
|
|
194
|
+
|
|
195
|
+
When you call `ff.file(...)`, the file is uploaded to **private storage** that
|
|
196
|
+
only the FlowFlex backend can read. It is **not** a public URL and the caller
|
|
197
|
+
cannot access it after upload.
|
|
198
|
+
|
|
199
|
+
### How long does the file stay?
|
|
200
|
+
|
|
201
|
+
| Phase | Duration |
|
|
202
|
+
| ----- | -------- |
|
|
203
|
+
| Presigned upload URL valid | **2 hours** from `create_upload_url` |
|
|
204
|
+
| File kept in storage | **48 hours** from upload |
|
|
205
|
+
| After 48 hours | File **deleted from storage** + record removed |
|
|
206
|
+
|
|
207
|
+
### What this means for you
|
|
208
|
+
|
|
209
|
+
- **Do not store `assetId` long-term** expecting to reuse it. It expires in 48h.
|
|
210
|
+
- **Each event send should get a fresh `assetId`** by calling `send_event` with
|
|
211
|
+
a new `ff.file(...)`. The SDK handles the upload automatically.
|
|
212
|
+
- If you fire the event more than 48h after uploading, the file will be gone and
|
|
213
|
+
the flow will fail with `ASSET_FILE_MISSING`. Keep your event send close to
|
|
214
|
+
the upload.
|
|
215
|
+
- **Re-sending the same message** to a different recipient after 48h requires a
|
|
216
|
+
fresh upload — call `send_event` again with the file, don't reuse the old assetId.
|
|
217
|
+
|
|
218
|
+
### Typical correct pattern
|
|
219
|
+
|
|
220
|
+
```python
|
|
221
|
+
# ✅ Upload + fire in the same operation — always fresh
|
|
222
|
+
ff.send_event("invoice.created", payload={"assetId": ff.file("./invoice.pdf")})
|
|
223
|
+
|
|
224
|
+
# ❌ Don't store assetId and reuse it hours later
|
|
225
|
+
result = ff.send_event(...)
|
|
226
|
+
# ... 50 hours later ...
|
|
227
|
+
# result.uploaded_assets["assetId"] is now expired and deleted
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
---
|
|
231
|
+
|
|
232
|
+
## Security
|
|
233
|
+
|
|
234
|
+
Your `api_key`/`api_secret` are integration-wide credentials — anyone who
|
|
235
|
+
obtains them can fire events and upload files as you. Keep them in environment
|
|
236
|
+
variables or a secrets manager, never in source control.
|
|
237
|
+
|
|
238
|
+
Protections built in:
|
|
239
|
+
|
|
240
|
+
- **HTTPS enforced.** `base_url` must be `https://` (only `localhost` may use
|
|
241
|
+
`http`), so Basic-auth credentials are never sent in cleartext.
|
|
242
|
+
- **No credential leakage.** The `Authorization` header is never included in
|
|
243
|
+
error messages or `FlowFlexError.details`.
|
|
244
|
+
- **Header-injection safe.** The `event` name and `idempotency_key` are rejected
|
|
245
|
+
if they contain control characters (CRLF).
|
|
246
|
+
- **Path-injection safe.** `integration_code` is URL-encoded into the request path.
|
|
247
|
+
- **Per-request timeouts** (default 30 s) on every network call.
|
|
248
|
+
- **Client-side size cap** (default 25 MB) so oversized files fail before upload.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
flowflex/__init__.py,sha256=NOB4fnoAS6Zvxvy4HOBTInsLP32AhGyvn_cco-J9Dkk,474
|
|
2
|
+
flowflex/client.py,sha256=6mn8JtXWhSq8IUP-UeX1yAu0f3QDcYM0tt3O_obhuJM,11610
|
|
3
|
+
flowflex/errors.py,sha256=OhOh0ip1gOuC5hRClLN38YjSBss-C9cBjZ-ynt25WgY,921
|
|
4
|
+
flowflex/file.py,sha256=Jb7zFnFgih4F5-sD-WVkWpG0rKyuLsw-nnc3H9JqZBY,4384
|
|
5
|
+
flowflex-0.1.0.dist-info/METADATA,sha256=1lNyRuizyskdXPR24jR4r_H2Vm7zxzVAXltP5Tp98lI,9078
|
|
6
|
+
flowflex-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
7
|
+
flowflex-0.1.0.dist-info/RECORD,,
|