flowmesh-sdk 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.
flowmesh/__init__.py ADDED
@@ -0,0 +1,55 @@
1
+ """FlowMesh Python SDK.
2
+
3
+ Provides typed sync and async clients for the FlowMesh Server REST API.
4
+
5
+ Quick start::
6
+
7
+ from flowmesh import FlowMesh
8
+
9
+ client = FlowMesh(base_url="https://kv.run:8000/flowmesh", api_key="flm-...")
10
+
11
+ # Submit a workflow
12
+ resp = client.workflows.submit(open("workflow.yaml").read())
13
+ print(resp.workflow_id)
14
+
15
+ # List workers
16
+ for w in client.workers.list():
17
+ print(w.id, w.status)
18
+
19
+ Async usage::
20
+
21
+ from flowmesh import AsyncFlowMesh
22
+
23
+ async with AsyncFlowMesh(base_url="...", api_key="...") as client:
24
+ workflows = await client.workflows.list()
25
+ """
26
+
27
+ from .async_client import AsyncFlowMesh
28
+ from .client import FlowMesh
29
+ from .config import FlowMeshConfig
30
+ from .exceptions import (
31
+ APIError,
32
+ AuthenticationError,
33
+ ConfigInvalidError,
34
+ ConfigNotFoundError,
35
+ FlowMeshConnectionError,
36
+ FlowMeshError,
37
+ NotFoundError,
38
+ ValidationError,
39
+ )
40
+
41
+ __all__ = [
42
+ "FlowMesh",
43
+ "AsyncFlowMesh",
44
+ "FlowMeshConfig",
45
+ "FlowMeshError",
46
+ "APIError",
47
+ "AuthenticationError",
48
+ "NotFoundError",
49
+ "ValidationError",
50
+ "FlowMeshConnectionError",
51
+ "ConfigNotFoundError",
52
+ "ConfigInvalidError",
53
+ ]
54
+
55
+ __version__ = "0.1.0"
@@ -0,0 +1,422 @@
1
+ """Base client implementation with shared HTTP transport logic."""
2
+
3
+ import asyncio
4
+ import json
5
+ import os
6
+ from collections.abc import AsyncIterator, Iterator
7
+ from pathlib import Path
8
+ from typing import Any, Self
9
+
10
+ import httpx
11
+
12
+ from ._constants import API_VERSION_PREFIX, DEFAULT_BASE_URL, STREAM_TIMEOUT, USER_AGENT
13
+ from .config import DEFAULT_CONFIG_PATH, FlowMeshConfig
14
+ from .exceptions import (
15
+ APIError,
16
+ AuthenticationError,
17
+ ConfigNotFoundError,
18
+ FlowMeshConnectionError,
19
+ NotFoundError,
20
+ ValidationError,
21
+ )
22
+
23
+
24
+ def _build_headers(api_key: str | None) -> dict[str, str]:
25
+ headers: dict[str, str] = {
26
+ "Accept": "application/json",
27
+ "User-Agent": USER_AGENT,
28
+ }
29
+ if api_key:
30
+ headers["Authorization"] = f"Bearer {api_key}"
31
+ return headers
32
+
33
+
34
+ def _make_url(base_url: str, path: str) -> str:
35
+ return base_url.rstrip("/") + API_VERSION_PREFIX + "/" + path.lstrip("/")
36
+
37
+
38
+ def _raise_for_status(response: httpx.Response, method: str) -> None:
39
+ if response.status_code < 400:
40
+ return
41
+ try:
42
+ body = response.json()
43
+ except Exception:
44
+ body = response.text
45
+
46
+ message = ""
47
+ if isinstance(body, dict):
48
+ message = body.get("detail", "") or body.get("message", "")
49
+ if not message:
50
+ message = str(body)
51
+
52
+ status = response.status_code
53
+ kwargs: dict[str, Any] = dict(
54
+ status_code=status,
55
+ method=method,
56
+ url=str(response.url),
57
+ body=body,
58
+ )
59
+ if status in (401, 403):
60
+ raise AuthenticationError(message, **kwargs)
61
+ if status == 404:
62
+ raise NotFoundError(message, **kwargs)
63
+ if status in (400, 422):
64
+ raise ValidationError(message, **kwargs)
65
+ raise APIError(message, **kwargs)
66
+
67
+
68
+ def _raise_for_stream_status(response: httpx.Response, method: str) -> None:
69
+ if response.status_code < 400:
70
+ return
71
+ response.read()
72
+ _raise_for_status(response, method)
73
+
74
+
75
+ async def _raise_for_stream_status_async(response: httpx.Response, method: str) -> None:
76
+ if response.status_code < 400:
77
+ return
78
+ await response.aread()
79
+ _raise_for_status(response, method)
80
+
81
+
82
+ class BaseClient:
83
+ """Synchronous HTTP transport for the FlowMesh API."""
84
+
85
+ def __init__(
86
+ self,
87
+ base_url: str,
88
+ api_key: str | None,
89
+ timeout: float,
90
+ http_client: httpx.Client | None,
91
+ ) -> None:
92
+ self.base_url = base_url.rstrip("/")
93
+ self.api_key = api_key
94
+ self._timeout = timeout
95
+ self._headers = _build_headers(api_key)
96
+ self._http = http_client or httpx.Client(
97
+ headers=self._headers,
98
+ timeout=httpx.Timeout(timeout),
99
+ )
100
+
101
+ def _request(
102
+ self,
103
+ method: str,
104
+ path: str,
105
+ params: dict[str, Any] | list[tuple[str, str]] | None = None,
106
+ json_body: Any = None,
107
+ data: str | bytes | None = None,
108
+ headers: dict[str, str] | None = None,
109
+ ) -> Any:
110
+ url = _make_url(self.base_url, path)
111
+ kwargs: dict[str, Any] = {}
112
+ if params:
113
+ kwargs["params"] = params
114
+ if json_body is not None:
115
+ kwargs["json"] = json_body
116
+ if data is not None:
117
+ kwargs["content"] = data
118
+ if headers:
119
+ kwargs["headers"] = headers
120
+ try:
121
+ response = self._http.request(method, url, **kwargs)
122
+ except httpx.ConnectError as exc:
123
+ raise FlowMeshConnectionError(f"Failed to connect to {url}: {exc}")
124
+ _raise_for_status(response, method)
125
+ if not response.content:
126
+ return None
127
+ return response.json()
128
+
129
+ def _request_raw(
130
+ self,
131
+ method: str,
132
+ path: str,
133
+ params: dict[str, Any] | list[tuple[str, str]] | None = None,
134
+ headers: dict[str, str] | None = None,
135
+ ) -> httpx.Response:
136
+ url = _make_url(self.base_url, path)
137
+ kwargs: dict[str, Any] = {}
138
+ if params:
139
+ kwargs["params"] = params
140
+ if headers:
141
+ kwargs["headers"] = headers
142
+ try:
143
+ response = self._http.request(method, url, **kwargs)
144
+ except httpx.ConnectError as exc:
145
+ raise FlowMeshConnectionError(f"Failed to connect to {url}: {exc}")
146
+ _raise_for_status(response, method)
147
+ return response
148
+
149
+ def _stream_sse(
150
+ self,
151
+ path: str,
152
+ cursor: str | None = None,
153
+ params: dict[str, str] | None = None,
154
+ ) -> Iterator[tuple[str | None, dict[str, Any]]]:
155
+ """Stream SSE log events, yielding cursor/data pairs."""
156
+ url = _make_url(self.base_url, path)
157
+ headers = self._headers.copy()
158
+ headers["Accept"] = "text/event-stream"
159
+ params = params.copy() if params else {}
160
+ if cursor:
161
+ params["cursor"] = cursor
162
+ try:
163
+ with self._http.stream(
164
+ "GET",
165
+ url,
166
+ params=params,
167
+ headers=headers,
168
+ timeout=httpx.Timeout(STREAM_TIMEOUT, connect=self._timeout),
169
+ ) as response:
170
+ _raise_for_stream_status(response, "GET")
171
+ current_data: list[str] = []
172
+ current_event: str | None = None
173
+ current_id: str | None = None
174
+ for raw_line in response.iter_lines():
175
+ line = raw_line.strip("\r")
176
+ if not line:
177
+ if current_data and (
178
+ current_event is None or current_event == "log"
179
+ ):
180
+ blob = "".join(current_data).strip()
181
+ if blob:
182
+ try:
183
+ yield current_id, json.loads(blob)
184
+ except json.JSONDecodeError:
185
+ yield current_id, {"message": blob}
186
+ current_data = []
187
+ current_event = None
188
+ current_id = None
189
+ continue
190
+ if line.startswith(":"):
191
+ continue
192
+ if line.startswith("id:"):
193
+ current_id = line.split(":", 1)[1].strip() or None
194
+ continue
195
+ if line.startswith("event:"):
196
+ current_event = line.split(":", 1)[1].strip() or None
197
+ continue
198
+ if line.startswith("data:"):
199
+ current_data.append(line.split(":", 1)[1].lstrip() + "\n")
200
+ continue
201
+ # flush remainder
202
+ if current_data and (current_event is None or current_event == "log"):
203
+ blob = "".join(current_data).strip()
204
+ if blob:
205
+ try:
206
+ yield current_id, json.loads(blob)
207
+ except json.JSONDecodeError:
208
+ yield current_id, {"message": blob}
209
+ except httpx.ConnectError as exc:
210
+ raise FlowMeshConnectionError(f"Failed to connect to {url}: {exc}")
211
+
212
+ def _download(
213
+ self,
214
+ path: str,
215
+ output_path: Path,
216
+ chunk_size: int = 256 * 1024,
217
+ ) -> None:
218
+ url = _make_url(self.base_url, path)
219
+ try:
220
+ with self._http.stream("GET", url) as response:
221
+ _raise_for_stream_status(response, "GET")
222
+ with output_path.open("wb") as fh:
223
+ for chunk in response.iter_bytes(chunk_size=chunk_size):
224
+ fh.write(chunk)
225
+ except httpx.ConnectError as exc:
226
+ raise FlowMeshConnectionError(f"Failed to connect to {url}: {exc}")
227
+
228
+ def close(self) -> None:
229
+ self._http.close()
230
+
231
+ def __enter__(self) -> Self:
232
+ return self
233
+
234
+ def __exit__(self, *args: Any) -> None:
235
+ self.close()
236
+
237
+
238
+ class BaseAsyncClient:
239
+ """Asynchronous HTTP transport for the FlowMesh API."""
240
+
241
+ def __init__(
242
+ self,
243
+ base_url: str,
244
+ api_key: str | None,
245
+ timeout: float,
246
+ http_client: httpx.AsyncClient | None,
247
+ ) -> None:
248
+ self.base_url = base_url.rstrip("/")
249
+ self.api_key = api_key
250
+ self._timeout = timeout
251
+ self._headers = _build_headers(api_key)
252
+ self._http = http_client or httpx.AsyncClient(
253
+ headers=self._headers,
254
+ timeout=httpx.Timeout(timeout),
255
+ )
256
+
257
+ async def _request(
258
+ self,
259
+ method: str,
260
+ path: str,
261
+ params: dict[str, Any] | list[tuple[str, str]] | None = None,
262
+ json_body: Any = None,
263
+ data: str | bytes | None = None,
264
+ headers: dict[str, str] | None = None,
265
+ ) -> Any:
266
+ url = _make_url(self.base_url, path)
267
+ kwargs: dict[str, Any] = {}
268
+ if params:
269
+ kwargs["params"] = params
270
+ if json_body is not None:
271
+ kwargs["json"] = json_body
272
+ if data is not None:
273
+ kwargs["content"] = data
274
+ if headers:
275
+ kwargs["headers"] = headers
276
+ try:
277
+ response = await self._http.request(method, url, **kwargs)
278
+ except httpx.ConnectError as exc:
279
+ raise FlowMeshConnectionError(f"Failed to connect to {url}: {exc}")
280
+ _raise_for_status(response, method)
281
+ if not response.content:
282
+ return None
283
+ return response.json()
284
+
285
+ async def _request_raw(
286
+ self,
287
+ method: str,
288
+ path: str,
289
+ params: dict[str, Any] | list[tuple[str, str]] | None = None,
290
+ headers: dict[str, str] | None = None,
291
+ ) -> httpx.Response:
292
+ url = _make_url(self.base_url, path)
293
+ kwargs: dict[str, Any] = {}
294
+ if params:
295
+ kwargs["params"] = params
296
+ if headers:
297
+ kwargs["headers"] = headers
298
+ try:
299
+ response = await self._http.request(method, url, **kwargs)
300
+ except httpx.ConnectError as exc:
301
+ raise FlowMeshConnectionError(f"Failed to connect to {url}: {exc}")
302
+ _raise_for_status(response, method)
303
+ return response
304
+
305
+ async def _stream_sse(
306
+ self,
307
+ path: str,
308
+ cursor: str | None = None,
309
+ params: dict[str, str] | None = None,
310
+ ) -> AsyncIterator[tuple[str | None, dict[str, Any]]]:
311
+ """Stream SSE log events, yielding cursor/data pairs."""
312
+ url = _make_url(self.base_url, path)
313
+ headers = dict(self._headers)
314
+ headers["Accept"] = "text/event-stream"
315
+ params = dict(params) if params else {}
316
+ if cursor:
317
+ params["cursor"] = cursor
318
+ try:
319
+ async with self._http.stream(
320
+ "GET",
321
+ url,
322
+ params=params,
323
+ headers=headers,
324
+ timeout=httpx.Timeout(STREAM_TIMEOUT, connect=self._timeout),
325
+ ) as response:
326
+ await _raise_for_stream_status_async(response, "GET")
327
+ current_data: list[str] = []
328
+ current_event: str | None = None
329
+ current_id: str | None = None
330
+ async for raw_line in response.aiter_lines():
331
+ line = raw_line.strip("\r")
332
+ if not line:
333
+ if current_data and (
334
+ current_event is None or current_event == "log"
335
+ ):
336
+ blob = "".join(current_data).strip()
337
+ if blob:
338
+ try:
339
+ yield current_id, json.loads(blob)
340
+ except json.JSONDecodeError:
341
+ yield current_id, {"message": blob}
342
+ current_data = []
343
+ current_event = None
344
+ current_id = None
345
+ continue
346
+ if line.startswith(":"):
347
+ continue
348
+ if line.startswith("id:"):
349
+ current_id = line.split(":", 1)[1].strip() or None
350
+ continue
351
+ if line.startswith("event:"):
352
+ current_event = line.split(":", 1)[1].strip() or None
353
+ continue
354
+ if line.startswith("data:"):
355
+ current_data.append(line.split(":", 1)[1].lstrip() + "\n")
356
+ continue
357
+ # flush remainder
358
+ if current_data and (current_event is None or current_event == "log"):
359
+ blob = "".join(current_data).strip()
360
+ if blob:
361
+ try:
362
+ yield current_id, json.loads(blob)
363
+ except json.JSONDecodeError:
364
+ yield current_id, {"message": blob}
365
+ except httpx.ConnectError as exc:
366
+ raise FlowMeshConnectionError(f"Failed to connect to {url}: {exc}")
367
+
368
+ async def _download(
369
+ self,
370
+ path: str,
371
+ output_path: Path,
372
+ chunk_size: int = 256 * 1024,
373
+ ) -> None:
374
+ url = _make_url(self.base_url, path)
375
+ try:
376
+ async with self._http.stream("GET", url) as response:
377
+ await _raise_for_stream_status_async(response, "GET")
378
+ fh = await asyncio.to_thread(output_path.open, "wb")
379
+ try:
380
+ async for chunk in response.aiter_bytes(chunk_size=chunk_size):
381
+ await asyncio.to_thread(fh.write, chunk)
382
+ finally:
383
+ await asyncio.to_thread(fh.close)
384
+ except httpx.ConnectError as exc:
385
+ raise FlowMeshConnectionError(f"Failed to connect to {url}: {exc}")
386
+
387
+ async def close(self) -> None:
388
+ await self._http.aclose()
389
+
390
+ async def __aenter__(self) -> Self:
391
+ return self
392
+
393
+ async def __aexit__(self, *args: Any) -> None:
394
+ await self.close()
395
+
396
+
397
+ def resolve_config(
398
+ base_url: str | None = None,
399
+ api_key: str | None = None,
400
+ config_path: Path | None = DEFAULT_CONFIG_PATH,
401
+ ) -> FlowMeshConfig:
402
+ """Resolve configuration from params → env → config file → defaults."""
403
+ if base_url is None:
404
+ base_url = os.getenv("FLOWMESH_BASE_URL", "").strip() or None
405
+ if api_key is None:
406
+ api_key = os.getenv("FLOWMESH_API_KEY", "").strip() or None
407
+
408
+ if not (base_url is None or api_key is None):
409
+ return FlowMeshConfig(base_url=base_url, api_key=api_key)
410
+
411
+ try:
412
+ cfg = FlowMeshConfig.from_file(config_path or DEFAULT_CONFIG_PATH)
413
+ except ConfigNotFoundError:
414
+ return FlowMeshConfig(
415
+ base_url=DEFAULT_BASE_URL if base_url is None else base_url, api_key=api_key
416
+ )
417
+
418
+ if base_url is not None:
419
+ cfg.base_url = base_url
420
+ if api_key is not None:
421
+ cfg.api_key = api_key
422
+ return cfg
flowmesh/_constants.py ADDED
@@ -0,0 +1,7 @@
1
+ """Internal constants for the FlowMesh SDK."""
2
+
3
+ API_VERSION_PREFIX = "/api/v1"
4
+ DEFAULT_TIMEOUT = 30.0
5
+ STREAM_TIMEOUT = 300.0
6
+ DEFAULT_BASE_URL = "http://localhost:8000"
7
+ USER_AGENT = "flowmesh-sdk-python/0.1.0"
@@ -0,0 +1,66 @@
1
+ """Asynchronous FlowMesh client."""
2
+
3
+ import httpx
4
+
5
+ from ._base_client import BaseAsyncClient, resolve_config
6
+ from ._constants import DEFAULT_TIMEOUT
7
+ from .resources.nodes import AsyncNodes
8
+ from .resources.results import AsyncResults
9
+ from .resources.ssh import AsyncSSH
10
+ from .resources.system import AsyncSystem
11
+ from .resources.tasks import AsyncTasks
12
+ from .resources.traces import AsyncTraces
13
+ from .resources.workers import AsyncWorkers
14
+ from .resources.workflows import AsyncWorkflows
15
+
16
+
17
+ class AsyncFlowMesh(BaseAsyncClient):
18
+ """Asynchronous FlowMesh API client.
19
+
20
+ Usage::
21
+
22
+ from flowmesh import AsyncFlowMesh
23
+
24
+ async with AsyncFlowMesh(
25
+ base_url="https://kv.run:8000/flowmesh",
26
+ api_key="flm-xxxx-...",
27
+ ) as client:
28
+ result = await client.workflows.submit(
29
+ open("workflow.yaml").read()
30
+ )
31
+ workers = await client.workers.list()
32
+
33
+ Configuration resolution is the same as :class:`FlowMesh`.
34
+ """
35
+
36
+ workflows: AsyncWorkflows
37
+ tasks: AsyncTasks
38
+ results: AsyncResults
39
+ workers: AsyncWorkers
40
+ nodes: AsyncNodes
41
+ ssh: AsyncSSH
42
+ system: AsyncSystem
43
+ traces: AsyncTraces
44
+
45
+ def __init__(
46
+ self,
47
+ base_url: str | None = None,
48
+ api_key: str | None = None,
49
+ timeout: float = DEFAULT_TIMEOUT,
50
+ http_client: httpx.AsyncClient | None = None,
51
+ ) -> None:
52
+ cfg = resolve_config(base_url, api_key)
53
+ super().__init__(
54
+ base_url=cfg.base_url,
55
+ api_key=cfg.api_key,
56
+ timeout=timeout,
57
+ http_client=http_client,
58
+ )
59
+ self.workflows = AsyncWorkflows(self)
60
+ self.tasks = AsyncTasks(self)
61
+ self.results = AsyncResults(self)
62
+ self.workers = AsyncWorkers(self)
63
+ self.nodes = AsyncNodes(self)
64
+ self.ssh = AsyncSSH(self)
65
+ self.system = AsyncSystem(self)
66
+ self.traces = AsyncTraces(self)
flowmesh/client.py ADDED
@@ -0,0 +1,76 @@
1
+ """Synchronous FlowMesh client."""
2
+
3
+ import httpx
4
+
5
+ from ._base_client import BaseClient, resolve_config
6
+ from ._constants import DEFAULT_TIMEOUT
7
+ from .resources.nodes import Nodes
8
+ from .resources.results import Results
9
+ from .resources.ssh import SSH
10
+ from .resources.system import System
11
+ from .resources.tasks import Tasks
12
+ from .resources.traces import Traces
13
+ from .resources.workers import Workers
14
+ from .resources.workflows import Workflows
15
+
16
+
17
+ class FlowMesh(BaseClient):
18
+ """Synchronous FlowMesh API client.
19
+
20
+ Usage::
21
+
22
+ from flowmesh import FlowMesh
23
+
24
+ client = FlowMesh(
25
+ base_url="https://kv.run:8000/flowmesh",
26
+ api_key="flm-xxxx-...",
27
+ )
28
+
29
+ # Submit a workflow
30
+ result = client.workflows.submit(open("workflow.yaml").read())
31
+
32
+ # List workers
33
+ workers = client.workers.list()
34
+
35
+ # Use as context manager
36
+ with FlowMesh(base_url="...", api_key="...") as client:
37
+ wf = client.workflows.retrieve("wf-123")
38
+
39
+ Configuration resolution (in order of precedence):
40
+ 1. Explicit ``base_url`` / ``api_key`` parameters
41
+ 2. Environment variables: ``FLOWMESH_BASE_URL``, ``FLOWMESH_API_KEY``
42
+ 3. CLI config file: ``~/.flowmesh/config.toml``
43
+ 4. Default: ``http://localhost:8000``
44
+ """
45
+
46
+ workflows: Workflows
47
+ tasks: Tasks
48
+ results: Results
49
+ workers: Workers
50
+ nodes: Nodes
51
+ ssh: SSH
52
+ system: System
53
+ traces: Traces
54
+
55
+ def __init__(
56
+ self,
57
+ base_url: str | None = None,
58
+ api_key: str | None = None,
59
+ timeout: float = DEFAULT_TIMEOUT,
60
+ http_client: httpx.Client | None = None,
61
+ ) -> None:
62
+ cfg = resolve_config(base_url, api_key)
63
+ super().__init__(
64
+ base_url=cfg.base_url,
65
+ api_key=cfg.api_key,
66
+ timeout=timeout,
67
+ http_client=http_client,
68
+ )
69
+ self.workflows = Workflows(self)
70
+ self.tasks = Tasks(self)
71
+ self.results = Results(self)
72
+ self.workers = Workers(self)
73
+ self.nodes = Nodes(self)
74
+ self.ssh = SSH(self)
75
+ self.system = System(self)
76
+ self.traces = Traces(self)