flowra-sdk 0.1.1__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.
- flowra/__init__.py +20 -0
- flowra/_http.py +237 -0
- flowra/client.py +943 -0
- flowra/errors.py +18 -0
- flowra/py.typed +0 -0
- flowra/stream_usage.py +81 -0
- flowra_sdk-0.1.1.dist-info/METADATA +89 -0
- flowra_sdk-0.1.1.dist-info/RECORD +10 -0
- flowra_sdk-0.1.1.dist-info/WHEEL +5 -0
- flowra_sdk-0.1.1.dist-info/top_level.txt +1 -0
flowra/__init__.py
ADDED
|
@@ -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"
|
flowra/_http.py
ADDED
|
@@ -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
|