flowra-sdk 0.1.1__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.
- flowra_sdk-0.1.1/PKG-INFO +89 -0
- flowra_sdk-0.1.1/README.md +75 -0
- flowra_sdk-0.1.1/pyproject.toml +26 -0
- flowra_sdk-0.1.1/setup.cfg +4 -0
- flowra_sdk-0.1.1/src/flowra/__init__.py +20 -0
- flowra_sdk-0.1.1/src/flowra/_http.py +237 -0
- flowra_sdk-0.1.1/src/flowra/client.py +943 -0
- flowra_sdk-0.1.1/src/flowra/errors.py +18 -0
- flowra_sdk-0.1.1/src/flowra/py.typed +0 -0
- flowra_sdk-0.1.1/src/flowra/stream_usage.py +81 -0
- flowra_sdk-0.1.1/src/flowra_sdk.egg-info/PKG-INFO +89 -0
- flowra_sdk-0.1.1/src/flowra_sdk.egg-info/SOURCES.txt +12 -0
- flowra_sdk-0.1.1/src/flowra_sdk.egg-info/dependency_links.txt +1 -0
- flowra_sdk-0.1.1/src/flowra_sdk.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: flowra-sdk
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Official Python SDK for the Flowra API
|
|
5
|
+
Author: Flowra
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://flowra.dev
|
|
8
|
+
Project-URL: Documentation, https://docs.flowra.dev
|
|
9
|
+
Project-URL: Repository, https://github.com/flowradev/sdk
|
|
10
|
+
Project-URL: Issues, https://github.com/flowradev/sdk/issues
|
|
11
|
+
Keywords: flowra,sdk,agents,workflows,tools
|
|
12
|
+
Requires-Python: >=3.10
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
|
|
15
|
+
# flowra
|
|
16
|
+
|
|
17
|
+
Python SDK for the Flowra API. Stdlib only (no extra runtime deps). Requires Python 3.10+.
|
|
18
|
+
|
|
19
|
+
## Install
|
|
20
|
+
|
|
21
|
+
PyPI name is `flowra-sdk`. Import stays `flowra`. Do **not** `pip install flowra` — that package is unrelated.
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
pip install flowra-sdk
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
From this repo:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
pip install -e "./python"
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Quickstart
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
import os
|
|
37
|
+
from flowra import Flowra
|
|
38
|
+
|
|
39
|
+
flowra = Flowra(api_key=os.environ["FLOWRA_API_KEY"])
|
|
40
|
+
|
|
41
|
+
profile = flowra.get_profile()
|
|
42
|
+
tools = flowra.tools.list(limit=10)
|
|
43
|
+
run = flowra.workflows.run("WORKFLOW_ID", {"input": {"message": "hello"}})
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Act as an external user:
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
user_client = flowra.as_user("customer_42")
|
|
50
|
+
user_client.connections.create_link({"authConfigId": "..."})
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Namespaces
|
|
54
|
+
|
|
55
|
+
`tools`, `toolkits`, `skills`, `workflows`, `connections`, `auth_configs`, `users`, `triggers`, `chat`, `files`, `knowledge`, `database`, `sandbox`, `mcp`, `llm`, `browser`, `usage`
|
|
56
|
+
|
|
57
|
+
Method names are snake_case mirrors of the TypeScript facade (e.g. `create_link`, `set_active`, `ingest_url`).
|
|
58
|
+
|
|
59
|
+
## Chat streaming and usage
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
from flowra import Flowra
|
|
63
|
+
|
|
64
|
+
flowra = Flowra(api_key="...")
|
|
65
|
+
|
|
66
|
+
for event in flowra.chat.stream(thread_id, {"input": {"messages": [...]}}, as_events=True):
|
|
67
|
+
if event["event"] == "usage":
|
|
68
|
+
print(event["data"])
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
balance = flowra.usage.balance()
|
|
73
|
+
ledger = flowra.usage.list(threadId="...")
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Files
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
flowra.files.upload("document", "/path/to/file.pdf")
|
|
80
|
+
content = flowra.files.download(file_id) # bytes
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## Escape hatch
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
flowra.request("GET", "/api/v1/toolkits", query={"limit": 5})
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
HTTP failures raise `FlowraAPIError` (`status_code`, `body`).
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# flowra
|
|
2
|
+
|
|
3
|
+
Python SDK for the Flowra API. Stdlib only (no extra runtime deps). Requires Python 3.10+.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
PyPI name is `flowra-sdk`. Import stays `flowra`. Do **not** `pip install flowra` — that package is unrelated.
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install flowra-sdk
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
From this repo:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
pip install -e "./python"
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Quickstart
|
|
20
|
+
|
|
21
|
+
```python
|
|
22
|
+
import os
|
|
23
|
+
from flowra import Flowra
|
|
24
|
+
|
|
25
|
+
flowra = Flowra(api_key=os.environ["FLOWRA_API_KEY"])
|
|
26
|
+
|
|
27
|
+
profile = flowra.get_profile()
|
|
28
|
+
tools = flowra.tools.list(limit=10)
|
|
29
|
+
run = flowra.workflows.run("WORKFLOW_ID", {"input": {"message": "hello"}})
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Act as an external user:
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
user_client = flowra.as_user("customer_42")
|
|
36
|
+
user_client.connections.create_link({"authConfigId": "..."})
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Namespaces
|
|
40
|
+
|
|
41
|
+
`tools`, `toolkits`, `skills`, `workflows`, `connections`, `auth_configs`, `users`, `triggers`, `chat`, `files`, `knowledge`, `database`, `sandbox`, `mcp`, `llm`, `browser`, `usage`
|
|
42
|
+
|
|
43
|
+
Method names are snake_case mirrors of the TypeScript facade (e.g. `create_link`, `set_active`, `ingest_url`).
|
|
44
|
+
|
|
45
|
+
## Chat streaming and usage
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
from flowra import Flowra
|
|
49
|
+
|
|
50
|
+
flowra = Flowra(api_key="...")
|
|
51
|
+
|
|
52
|
+
for event in flowra.chat.stream(thread_id, {"input": {"messages": [...]}}, as_events=True):
|
|
53
|
+
if event["event"] == "usage":
|
|
54
|
+
print(event["data"])
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
balance = flowra.usage.balance()
|
|
59
|
+
ledger = flowra.usage.list(threadId="...")
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Files
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
flowra.files.upload("document", "/path/to/file.pdf")
|
|
66
|
+
content = flowra.files.download(file_id) # bytes
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Escape hatch
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
flowra.request("GET", "/api/v1/toolkits", query={"limit": 5})
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
HTTP failures raise `FlowraAPIError` (`status_code`, `body`).
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "flowra-sdk"
|
|
7
|
+
version = "0.1.1"
|
|
8
|
+
description = "Official Python SDK for the Flowra API"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "Flowra" }]
|
|
13
|
+
keywords = ["flowra", "sdk", "agents", "workflows", "tools"]
|
|
14
|
+
dependencies = []
|
|
15
|
+
|
|
16
|
+
[project.urls]
|
|
17
|
+
Homepage = "https://flowra.dev"
|
|
18
|
+
Documentation = "https://docs.flowra.dev"
|
|
19
|
+
Repository = "https://github.com/flowradev/sdk"
|
|
20
|
+
Issues = "https://github.com/flowradev/sdk/issues"
|
|
21
|
+
|
|
22
|
+
[tool.setuptools.packages.find]
|
|
23
|
+
where = ["src"]
|
|
24
|
+
|
|
25
|
+
[tool.setuptools.package-data]
|
|
26
|
+
flowra = ["py.typed"]
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Flowra Python SDK."""
|
|
2
|
+
|
|
3
|
+
from .client import Flowra
|
|
4
|
+
from .errors import FlowraAPIError
|
|
5
|
+
from .stream_usage import (
|
|
6
|
+
ParsedSseEvent,
|
|
7
|
+
StreamUsageEvent,
|
|
8
|
+
extract_stream_usage,
|
|
9
|
+
parse_sse_chunk,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"Flowra",
|
|
14
|
+
"FlowraAPIError",
|
|
15
|
+
"ParsedSseEvent",
|
|
16
|
+
"StreamUsageEvent",
|
|
17
|
+
"extract_stream_usage",
|
|
18
|
+
"parse_sse_chunk",
|
|
19
|
+
]
|
|
20
|
+
__version__ = "0.1.1"
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
"""HTTP helpers for the Flowra SDK."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import mimetypes
|
|
7
|
+
import uuid
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, BinaryIO, Iterator, Mapping, MutableMapping, Optional, Union
|
|
10
|
+
from urllib.error import HTTPError, URLError
|
|
11
|
+
from urllib.parse import urlencode, urljoin
|
|
12
|
+
from urllib.request import Request, urlopen
|
|
13
|
+
|
|
14
|
+
from .errors import FlowraAPIError
|
|
15
|
+
|
|
16
|
+
FileBody = Union[str, Path, bytes, BinaryIO]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class HttpClient:
|
|
20
|
+
def __init__(
|
|
21
|
+
self,
|
|
22
|
+
*,
|
|
23
|
+
api_key: str,
|
|
24
|
+
base_url: str = "https://flowra.dev",
|
|
25
|
+
username: Optional[str] = None,
|
|
26
|
+
timeout: float = 60.0,
|
|
27
|
+
) -> None:
|
|
28
|
+
self.api_key = api_key
|
|
29
|
+
self.base_url = base_url.rstrip("/")
|
|
30
|
+
self.username = username
|
|
31
|
+
self.timeout = timeout
|
|
32
|
+
|
|
33
|
+
def _build_url(
|
|
34
|
+
self,
|
|
35
|
+
path: str,
|
|
36
|
+
query: Optional[Mapping[str, Any]] = None,
|
|
37
|
+
) -> str:
|
|
38
|
+
url = urljoin(f"{self.base_url}/", path.lstrip("/"))
|
|
39
|
+
if query:
|
|
40
|
+
filtered = {
|
|
41
|
+
key: value
|
|
42
|
+
for key, value in query.items()
|
|
43
|
+
if value is not None
|
|
44
|
+
}
|
|
45
|
+
if filtered:
|
|
46
|
+
url = f"{url}?{urlencode(filtered, doseq=True)}"
|
|
47
|
+
return url
|
|
48
|
+
|
|
49
|
+
def _base_headers(
|
|
50
|
+
self,
|
|
51
|
+
*,
|
|
52
|
+
accept: str = "application/json",
|
|
53
|
+
) -> MutableMapping[str, str]:
|
|
54
|
+
headers: MutableMapping[str, str] = {
|
|
55
|
+
"Accept": accept,
|
|
56
|
+
"x-api-key": self.api_key,
|
|
57
|
+
}
|
|
58
|
+
if self.username:
|
|
59
|
+
headers["x-username"] = self.username
|
|
60
|
+
return headers
|
|
61
|
+
|
|
62
|
+
def _raise_http_error(self, exc: HTTPError) -> None:
|
|
63
|
+
body: Any
|
|
64
|
+
try:
|
|
65
|
+
body = json.loads(exc.read().decode("utf-8"))
|
|
66
|
+
except Exception:
|
|
67
|
+
body = None
|
|
68
|
+
message = None
|
|
69
|
+
if isinstance(body, dict):
|
|
70
|
+
message = body.get("message") or body.get("error")
|
|
71
|
+
raise FlowraAPIError(
|
|
72
|
+
message or f"HTTP {exc.code}",
|
|
73
|
+
status_code=exc.code,
|
|
74
|
+
body=body,
|
|
75
|
+
) from exc
|
|
76
|
+
|
|
77
|
+
def request(
|
|
78
|
+
self,
|
|
79
|
+
method: str,
|
|
80
|
+
path: str,
|
|
81
|
+
*,
|
|
82
|
+
query: Optional[Mapping[str, Any]] = None,
|
|
83
|
+
json_body: Any = None,
|
|
84
|
+
binary: bool = False,
|
|
85
|
+
extra_headers: Optional[Mapping[str, str]] = None,
|
|
86
|
+
) -> Any:
|
|
87
|
+
url = self._build_url(path, query)
|
|
88
|
+
headers = self._base_headers(
|
|
89
|
+
accept="application/octet-stream" if binary else "application/json",
|
|
90
|
+
)
|
|
91
|
+
if extra_headers:
|
|
92
|
+
headers.update(extra_headers)
|
|
93
|
+
|
|
94
|
+
data: Optional[bytes] = None
|
|
95
|
+
if json_body is not None:
|
|
96
|
+
headers["Content-Type"] = "application/json"
|
|
97
|
+
data = json.dumps(json_body).encode("utf-8")
|
|
98
|
+
|
|
99
|
+
request = Request(url, data=data, headers=dict(headers), method=method.upper())
|
|
100
|
+
try:
|
|
101
|
+
with urlopen(request, timeout=self.timeout) as response:
|
|
102
|
+
raw = response.read()
|
|
103
|
+
if not raw:
|
|
104
|
+
return None
|
|
105
|
+
if binary:
|
|
106
|
+
return raw
|
|
107
|
+
content_type = response.headers.get("Content-Type", "")
|
|
108
|
+
if "application/json" in content_type:
|
|
109
|
+
return json.loads(raw.decode("utf-8"))
|
|
110
|
+
return raw.decode("utf-8")
|
|
111
|
+
except HTTPError as exc:
|
|
112
|
+
self._raise_http_error(exc)
|
|
113
|
+
except URLError as exc:
|
|
114
|
+
raise FlowraAPIError(str(exc.reason)) from exc
|
|
115
|
+
|
|
116
|
+
def request_multipart(
|
|
117
|
+
self,
|
|
118
|
+
method: str,
|
|
119
|
+
path: str,
|
|
120
|
+
*,
|
|
121
|
+
query: Optional[Mapping[str, Any]] = None,
|
|
122
|
+
fields: Optional[Mapping[str, Any]] = None,
|
|
123
|
+
files: Optional[Mapping[str, FileBody]] = None,
|
|
124
|
+
file_field: str = "file",
|
|
125
|
+
) -> Any:
|
|
126
|
+
"""POST/PUT multipart/form-data (used for file upload)."""
|
|
127
|
+
url = self._build_url(path, query)
|
|
128
|
+
boundary = f"----FlowraFormBoundary{uuid.uuid4().hex}"
|
|
129
|
+
body = bytearray()
|
|
130
|
+
|
|
131
|
+
for key, value in (fields or {}).items():
|
|
132
|
+
if value is None:
|
|
133
|
+
continue
|
|
134
|
+
body.extend(f"--{boundary}\r\n".encode("utf-8"))
|
|
135
|
+
body.extend(
|
|
136
|
+
f'Content-Disposition: form-data; name="{key}"\r\n\r\n'.encode("utf-8")
|
|
137
|
+
)
|
|
138
|
+
body.extend(str(value).encode("utf-8"))
|
|
139
|
+
body.extend(b"\r\n")
|
|
140
|
+
|
|
141
|
+
for name, file_value in (files or {}).items():
|
|
142
|
+
filename, content, content_type = self._read_file(file_value)
|
|
143
|
+
field_name = name or file_field
|
|
144
|
+
body.extend(f"--{boundary}\r\n".encode("utf-8"))
|
|
145
|
+
body.extend(
|
|
146
|
+
(
|
|
147
|
+
f'Content-Disposition: form-data; name="{field_name}"; '
|
|
148
|
+
f'filename="{filename}"\r\n'
|
|
149
|
+
).encode("utf-8")
|
|
150
|
+
)
|
|
151
|
+
body.extend(f"Content-Type: {content_type}\r\n\r\n".encode("utf-8"))
|
|
152
|
+
body.extend(content)
|
|
153
|
+
body.extend(b"\r\n")
|
|
154
|
+
|
|
155
|
+
body.extend(f"--{boundary}--\r\n".encode("utf-8"))
|
|
156
|
+
|
|
157
|
+
headers = self._base_headers()
|
|
158
|
+
headers["Content-Type"] = f"multipart/form-data; boundary={boundary}"
|
|
159
|
+
|
|
160
|
+
request = Request(
|
|
161
|
+
url,
|
|
162
|
+
data=bytes(body),
|
|
163
|
+
headers=dict(headers),
|
|
164
|
+
method=method.upper(),
|
|
165
|
+
)
|
|
166
|
+
try:
|
|
167
|
+
with urlopen(request, timeout=self.timeout) as response:
|
|
168
|
+
raw = response.read()
|
|
169
|
+
if not raw:
|
|
170
|
+
return None
|
|
171
|
+
content_type = response.headers.get("Content-Type", "")
|
|
172
|
+
if "application/json" in content_type:
|
|
173
|
+
return json.loads(raw.decode("utf-8"))
|
|
174
|
+
return raw.decode("utf-8")
|
|
175
|
+
except HTTPError as exc:
|
|
176
|
+
self._raise_http_error(exc)
|
|
177
|
+
except URLError as exc:
|
|
178
|
+
raise FlowraAPIError(str(exc.reason)) from exc
|
|
179
|
+
|
|
180
|
+
def stream_text(
|
|
181
|
+
self,
|
|
182
|
+
method: str,
|
|
183
|
+
path: str,
|
|
184
|
+
*,
|
|
185
|
+
query: Optional[Mapping[str, Any]] = None,
|
|
186
|
+
json_body: Any = None,
|
|
187
|
+
extra_headers: Optional[Mapping[str, str]] = None,
|
|
188
|
+
chunk_size: int = 1024,
|
|
189
|
+
) -> Iterator[str]:
|
|
190
|
+
"""Yield decoded text chunks from a streaming response (SSE)."""
|
|
191
|
+
url = self._build_url(path, query)
|
|
192
|
+
headers = self._base_headers(accept="text/event-stream")
|
|
193
|
+
if extra_headers:
|
|
194
|
+
headers.update(extra_headers)
|
|
195
|
+
data: Optional[bytes] = None
|
|
196
|
+
if json_body is not None:
|
|
197
|
+
headers["Content-Type"] = "application/json"
|
|
198
|
+
data = json.dumps(json_body).encode("utf-8")
|
|
199
|
+
|
|
200
|
+
request = Request(url, data=data, headers=dict(headers), method=method.upper())
|
|
201
|
+
try:
|
|
202
|
+
response = urlopen(request, timeout=self.timeout)
|
|
203
|
+
except HTTPError as exc:
|
|
204
|
+
self._raise_http_error(exc)
|
|
205
|
+
return # pragma: no cover
|
|
206
|
+
except URLError as exc:
|
|
207
|
+
raise FlowraAPIError(str(exc.reason)) from exc
|
|
208
|
+
|
|
209
|
+
try:
|
|
210
|
+
while True:
|
|
211
|
+
chunk = response.read(chunk_size)
|
|
212
|
+
if not chunk:
|
|
213
|
+
break
|
|
214
|
+
yield chunk.decode("utf-8", errors="replace")
|
|
215
|
+
finally:
|
|
216
|
+
response.close()
|
|
217
|
+
|
|
218
|
+
@staticmethod
|
|
219
|
+
def _read_file(file_value: FileBody) -> tuple[str, bytes, str]:
|
|
220
|
+
if isinstance(file_value, (str, Path)):
|
|
221
|
+
path = Path(file_value)
|
|
222
|
+
content = path.read_bytes()
|
|
223
|
+
filename = path.name
|
|
224
|
+
content_type = mimetypes.guess_type(filename)[0] or "application/octet-stream"
|
|
225
|
+
return filename, content, content_type
|
|
226
|
+
|
|
227
|
+
if isinstance(file_value, bytes):
|
|
228
|
+
return "upload.bin", file_value, "application/octet-stream"
|
|
229
|
+
|
|
230
|
+
# BinaryIO / file-like
|
|
231
|
+
name = getattr(file_value, "name", "upload.bin")
|
|
232
|
+
filename = Path(str(name)).name if name else "upload.bin"
|
|
233
|
+
content = file_value.read()
|
|
234
|
+
if isinstance(content, str):
|
|
235
|
+
content = content.encode("utf-8")
|
|
236
|
+
content_type = mimetypes.guess_type(filename)[0] or "application/octet-stream"
|
|
237
|
+
return filename, content, content_type
|