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 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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any