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 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