syfthub-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.
@@ -0,0 +1,152 @@
1
+ """SyftHub SDK - Python client for interacting with SyftHub API.
2
+
3
+ Example usage:
4
+ from syfthub_sdk import SyftHubClient
5
+
6
+ client = SyftHubClient(base_url="https://hub.syft.com")
7
+ client.auth.login(username="john", password="secret123")
8
+
9
+ # Your endpoints
10
+ for endpoint in client.my_endpoints.list():
11
+ print(endpoint.name)
12
+
13
+ # Browse public endpoints
14
+ for endpoint in client.hub.browse():
15
+ print(f"{endpoint.path}: {endpoint.name}")
16
+
17
+ # Chat with RAG via aggregator
18
+ response = client.chat.complete(
19
+ prompt="What is machine learning?",
20
+ model="alice/gpt-model",
21
+ data_sources=["bob/ml-docs"],
22
+ )
23
+ print(response.response)
24
+
25
+ # Streaming chat
26
+ for event in client.chat.stream(prompt="...", model="..."):
27
+ if event.type == "token":
28
+ print(event.content, end="")
29
+ """
30
+
31
+ from syfthub_sdk._pagination import PageIterator
32
+ from syfthub_sdk.chat import (
33
+ ChatResource,
34
+ ChatStreamEvent,
35
+ DoneEvent,
36
+ ErrorEvent,
37
+ GenerationStartEvent,
38
+ RetrievalCompleteEvent,
39
+ RetrievalStartEvent,
40
+ SourceCompleteEvent,
41
+ TokenEvent,
42
+ )
43
+ from syfthub_sdk.client import SyftHubClient
44
+ from syfthub_sdk.exceptions import (
45
+ AggregatorError,
46
+ APIError,
47
+ AuthenticationError,
48
+ AuthorizationError,
49
+ ChatError,
50
+ ConfigurationError,
51
+ EndpointResolutionError,
52
+ GenerationError,
53
+ NetworkError,
54
+ NotFoundError,
55
+ RetrievalError,
56
+ SyftHubError,
57
+ UserAlreadyExistsError,
58
+ ValidationError,
59
+ )
60
+ from syfthub_sdk.models import (
61
+ AccountingCredentials,
62
+ AccountingUser,
63
+ AuthTokens,
64
+ ChatMetadata,
65
+ ChatResponse,
66
+ Connection,
67
+ CreatorType,
68
+ Document,
69
+ DocumentSource,
70
+ Endpoint,
71
+ EndpointPublic,
72
+ EndpointRef,
73
+ EndpointType,
74
+ Message,
75
+ OrganizationRole,
76
+ Policy,
77
+ SatelliteTokenResponse,
78
+ SourceInfo,
79
+ SourceStatus,
80
+ TokenUsage,
81
+ Transaction,
82
+ TransactionStatus,
83
+ User,
84
+ UserRole,
85
+ Visibility,
86
+ )
87
+ from syfthub_sdk.syftai import SyftAIResource
88
+
89
+ __version__ = "0.1.0"
90
+
91
+ __all__ = [
92
+ # Main client
93
+ "SyftHubClient",
94
+ # Core models
95
+ "User",
96
+ "UserRole",
97
+ "OrganizationRole",
98
+ "Endpoint",
99
+ "EndpointPublic",
100
+ "EndpointType",
101
+ "AuthTokens",
102
+ "SatelliteTokenResponse",
103
+ "Visibility",
104
+ "Policy",
105
+ "Connection",
106
+ # Chat models
107
+ "EndpointRef",
108
+ "Document",
109
+ "DocumentSource",
110
+ "SourceInfo",
111
+ "SourceStatus",
112
+ "ChatMetadata",
113
+ "ChatResponse",
114
+ "TokenUsage",
115
+ "Message",
116
+ # Chat streaming events
117
+ "ChatStreamEvent",
118
+ "RetrievalStartEvent",
119
+ "SourceCompleteEvent",
120
+ "RetrievalCompleteEvent",
121
+ "GenerationStartEvent",
122
+ "TokenEvent",
123
+ "DoneEvent",
124
+ "ErrorEvent",
125
+ # Resources (for type hints)
126
+ "ChatResource",
127
+ "SyftAIResource",
128
+ # Accounting models
129
+ "AccountingCredentials",
130
+ "AccountingUser",
131
+ "Transaction",
132
+ "TransactionStatus",
133
+ "CreatorType",
134
+ # Exceptions
135
+ "SyftHubError",
136
+ "AuthenticationError",
137
+ "AuthorizationError",
138
+ "NotFoundError",
139
+ "ValidationError",
140
+ "APIError",
141
+ "ConfigurationError",
142
+ "NetworkError",
143
+ "UserAlreadyExistsError",
144
+ # Chat exceptions
145
+ "ChatError",
146
+ "AggregatorError",
147
+ "RetrievalError",
148
+ "GenerationError",
149
+ "EndpointResolutionError",
150
+ # Utilities
151
+ "PageIterator",
152
+ ]
syfthub_sdk/_http.py ADDED
@@ -0,0 +1,327 @@
1
+ """Internal HTTP client for SyftHub SDK."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ import httpx
8
+
9
+ from syfthub_sdk.exceptions import (
10
+ AccountingAccountExistsError,
11
+ AccountingServiceUnavailableError,
12
+ APIError,
13
+ AuthenticationError,
14
+ AuthorizationError,
15
+ InvalidAccountingPasswordError,
16
+ NetworkError,
17
+ NotFoundError,
18
+ SyftHubError,
19
+ ValidationError,
20
+ )
21
+ from syfthub_sdk.models import AuthTokens
22
+
23
+
24
+ class HTTPClient:
25
+ """HTTP client with automatic token management."""
26
+
27
+ def __init__(
28
+ self,
29
+ base_url: str,
30
+ timeout: float = 30.0,
31
+ ) -> None:
32
+ """Initialize HTTP client.
33
+
34
+ Args:
35
+ base_url: Base URL for the API (e.g., "https://hub.syft.com")
36
+ timeout: Request timeout in seconds
37
+ """
38
+ # Ensure base_url doesn't end with /
39
+ self.base_url = base_url.rstrip("/")
40
+ self.timeout = timeout
41
+
42
+ # Token storage
43
+ self._access_token: str | None = None
44
+ self._refresh_token: str | None = None
45
+
46
+ # HTTP client
47
+ self._client = httpx.Client(timeout=timeout)
48
+
49
+ def close(self) -> None:
50
+ """Close the HTTP client."""
51
+ self._client.close()
52
+
53
+ @property
54
+ def is_authenticated(self) -> bool:
55
+ """Check if client has tokens set."""
56
+ return self._access_token is not None
57
+
58
+ def set_tokens(self, tokens: AuthTokens) -> None:
59
+ """Set authentication tokens.
60
+
61
+ Args:
62
+ tokens: AuthTokens with access_token and refresh_token
63
+ """
64
+ self._access_token = tokens.access_token
65
+ self._refresh_token = tokens.refresh_token
66
+
67
+ def get_tokens(self) -> AuthTokens | None:
68
+ """Get current authentication tokens.
69
+
70
+ Returns:
71
+ AuthTokens if authenticated, None otherwise
72
+ """
73
+ if self._access_token and self._refresh_token:
74
+ return AuthTokens(
75
+ access_token=self._access_token,
76
+ refresh_token=self._refresh_token,
77
+ )
78
+ return None
79
+
80
+ def clear_tokens(self) -> None:
81
+ """Clear authentication tokens."""
82
+ self._access_token = None
83
+ self._refresh_token = None
84
+
85
+ def _get_headers(self, include_auth: bool = True) -> dict[str, str]:
86
+ """Build request headers.
87
+
88
+ Args:
89
+ include_auth: Whether to include Authorization header
90
+
91
+ Returns:
92
+ Dictionary of headers
93
+ """
94
+ headers = {
95
+ "Content-Type": "application/json",
96
+ "Accept": "application/json",
97
+ }
98
+ if include_auth and self._access_token:
99
+ headers["Authorization"] = f"Bearer {self._access_token}"
100
+ return headers
101
+
102
+ def _handle_error(self, response: httpx.Response) -> None:
103
+ """Convert HTTP errors to SDK exceptions.
104
+
105
+ Args:
106
+ response: HTTP response object
107
+
108
+ Raises:
109
+ SyftHubError: Appropriate exception based on status code
110
+ """
111
+ status = response.status_code
112
+ try:
113
+ detail = response.json()
114
+ except Exception:
115
+ detail = response.text
116
+
117
+ # Extract message from detail if it's a dict
118
+ # Handle both {"detail": {...}} and {"detail": "string"} formats
119
+ inner_detail = (
120
+ detail.get("detail", detail) if isinstance(detail, dict) else detail
121
+ )
122
+
123
+ if isinstance(inner_detail, dict):
124
+ message = inner_detail.get("message", str(inner_detail))
125
+ error_code = inner_detail.get("code")
126
+ else:
127
+ message = str(inner_detail)
128
+ error_code = None
129
+
130
+ # Check for accounting-specific errors based on error code
131
+ if isinstance(inner_detail, dict) and error_code:
132
+ if error_code == "ACCOUNTING_ACCOUNT_EXISTS":
133
+ raise AccountingAccountExistsError(message=message, detail=inner_detail)
134
+ elif error_code == "INVALID_ACCOUNTING_PASSWORD":
135
+ raise InvalidAccountingPasswordError(
136
+ message=message, detail=inner_detail
137
+ )
138
+ elif error_code == "ACCOUNTING_SERVICE_UNAVAILABLE":
139
+ raise AccountingServiceUnavailableError(
140
+ message=message, detail=inner_detail
141
+ )
142
+
143
+ # Standard error handling based on status code
144
+ if status == 401:
145
+ raise AuthenticationError(message=message, detail=detail)
146
+ elif status == 403:
147
+ raise AuthorizationError(message=message, detail=detail)
148
+ elif status == 404:
149
+ raise NotFoundError(message=message, detail=detail)
150
+ elif status == 422:
151
+ raise ValidationError(message=message, detail=detail)
152
+ else:
153
+ raise APIError(
154
+ message=message,
155
+ status_code=status,
156
+ detail=detail,
157
+ )
158
+
159
+ def _attempt_refresh(self) -> bool:
160
+ """Attempt to refresh the access token.
161
+
162
+ Returns:
163
+ True if refresh succeeded, False otherwise
164
+ """
165
+ if not self._refresh_token:
166
+ return False
167
+
168
+ try:
169
+ response = self._client.post(
170
+ f"{self.base_url}/api/v1/auth/refresh",
171
+ headers={"Content-Type": "application/json"},
172
+ json={"refresh_token": self._refresh_token},
173
+ )
174
+ if response.status_code == 200:
175
+ data = response.json()
176
+ self._access_token = data["access_token"]
177
+ self._refresh_token = data["refresh_token"]
178
+ return True
179
+ except Exception:
180
+ pass
181
+ return False
182
+
183
+ def request(
184
+ self,
185
+ method: str,
186
+ path: str,
187
+ *,
188
+ json: dict[str, Any] | None = None,
189
+ params: dict[str, Any] | None = None,
190
+ data: dict[str, Any] | None = None,
191
+ include_auth: bool = True,
192
+ retry_on_401: bool = True,
193
+ ) -> dict[str, Any] | list[Any]:
194
+ """Make an HTTP request.
195
+
196
+ Args:
197
+ method: HTTP method (GET, POST, PUT, PATCH, DELETE)
198
+ path: URL path (will be joined with base_url)
199
+ json: JSON body data
200
+ params: Query parameters
201
+ data: Form data (for login endpoint)
202
+ include_auth: Whether to include Authorization header
203
+ retry_on_401: Whether to retry with token refresh on 401
204
+
205
+ Returns:
206
+ Parsed JSON response
207
+
208
+ Raises:
209
+ SyftHubError: On API errors
210
+ """
211
+ url = f"{self.base_url}{path}"
212
+ headers = self._get_headers(include_auth=include_auth)
213
+
214
+ # For form data (OAuth2 login), adjust content type
215
+ if data is not None:
216
+ headers["Content-Type"] = "application/x-www-form-urlencoded"
217
+
218
+ try:
219
+ response = self._client.request(
220
+ method=method,
221
+ url=url,
222
+ headers=headers,
223
+ json=json,
224
+ params=params,
225
+ data=data,
226
+ )
227
+ except httpx.TimeoutException as e:
228
+ raise NetworkError(
229
+ message="Request timed out",
230
+ cause=e,
231
+ detail=str(e),
232
+ ) from e
233
+ except httpx.RequestError as e:
234
+ raise NetworkError(
235
+ message=str(e) or "Network request failed",
236
+ cause=e,
237
+ detail=str(e),
238
+ ) from e
239
+ except Exception as e:
240
+ # Re-raise SyftHubErrors as-is
241
+ if isinstance(e, SyftHubError):
242
+ raise
243
+ raise NetworkError(
244
+ message="Unknown network error",
245
+ cause=e,
246
+ detail=str(e),
247
+ ) from e
248
+
249
+ # Handle 401 with token refresh
250
+ if (
251
+ response.status_code == 401
252
+ and retry_on_401
253
+ and include_auth
254
+ and self._attempt_refresh()
255
+ ):
256
+ # Retry with new token
257
+ return self.request(
258
+ method=method,
259
+ path=path,
260
+ json=json,
261
+ params=params,
262
+ data=data,
263
+ include_auth=include_auth,
264
+ retry_on_401=False, # Don't retry again
265
+ )
266
+
267
+ # Handle errors
268
+ if response.status_code >= 400:
269
+ self._handle_error(response)
270
+
271
+ # Return parsed JSON (or empty dict for 204 No Content)
272
+ if response.status_code == 204:
273
+ return {}
274
+
275
+ return response.json() # type: ignore[no-any-return]
276
+
277
+ def get(
278
+ self,
279
+ path: str,
280
+ *,
281
+ params: dict[str, Any] | None = None,
282
+ include_auth: bool = True,
283
+ ) -> dict[str, Any] | list[Any]:
284
+ """Make a GET request."""
285
+ return self.request("GET", path, params=params, include_auth=include_auth)
286
+
287
+ def post(
288
+ self,
289
+ path: str,
290
+ *,
291
+ json: dict[str, Any] | None = None,
292
+ data: dict[str, Any] | None = None,
293
+ include_auth: bool = True,
294
+ ) -> dict[str, Any] | list[Any]:
295
+ """Make a POST request."""
296
+ return self.request(
297
+ "POST", path, json=json, data=data, include_auth=include_auth
298
+ )
299
+
300
+ def put(
301
+ self,
302
+ path: str,
303
+ *,
304
+ json: dict[str, Any] | None = None,
305
+ include_auth: bool = True,
306
+ ) -> dict[str, Any] | list[Any]:
307
+ """Make a PUT request."""
308
+ return self.request("PUT", path, json=json, include_auth=include_auth)
309
+
310
+ def patch(
311
+ self,
312
+ path: str,
313
+ *,
314
+ json: dict[str, Any] | None = None,
315
+ include_auth: bool = True,
316
+ ) -> dict[str, Any] | list[Any]:
317
+ """Make a PATCH request."""
318
+ return self.request("PATCH", path, json=json, include_auth=include_auth)
319
+
320
+ def delete(
321
+ self,
322
+ path: str,
323
+ *,
324
+ include_auth: bool = True,
325
+ ) -> dict[str, Any] | list[Any]:
326
+ """Make a DELETE request."""
327
+ return self.request("DELETE", path, include_auth=include_auth)
@@ -0,0 +1,133 @@
1
+ """Pagination utilities for SyftHub SDK."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable, Iterator
6
+ from typing import Generic, TypeVar
7
+
8
+ from pydantic import BaseModel
9
+
10
+ T = TypeVar("T", bound=BaseModel)
11
+
12
+ # Type alias for fetch functions
13
+ FetchFn = Callable[[int, int], list[dict[str, object]]]
14
+
15
+
16
+ class PageIterator(Generic[T]):
17
+ """Lazy pagination iterator that fetches pages on demand.
18
+
19
+ Example usage:
20
+ # Iterate through all items
21
+ for endpoint in client.hub.browse():
22
+ print(endpoint.name)
23
+
24
+ # Get just the first page
25
+ first_page = client.hub.browse().first_page()
26
+
27
+ # Get all items as a list
28
+ all_items = client.hub.browse().all()
29
+
30
+ # Get first 50 items
31
+ items = client.hub.browse().take(50)
32
+ """
33
+
34
+ def __init__(
35
+ self,
36
+ fetch_fn: FetchFn,
37
+ model_class: type[T],
38
+ page_size: int = 20,
39
+ ) -> None:
40
+ """Initialize the page iterator.
41
+
42
+ Args:
43
+ fetch_fn: Function that takes (skip, limit) and returns list of dicts
44
+ model_class: Pydantic model class to parse items into
45
+ page_size: Number of items per page (default 20)
46
+ """
47
+ self._fetch_fn = fetch_fn
48
+ self._model_class = model_class
49
+ self._page_size = page_size
50
+ self._reset()
51
+
52
+ def _reset(self) -> None:
53
+ """Reset iterator state for fresh iteration."""
54
+ self._buffer: list[T] = []
55
+ self._current_page = 0
56
+ self._exhausted = False
57
+ self._started = False
58
+
59
+ def _fetch_page(self, page: int) -> list[T]:
60
+ """Fetch a single page and convert to models."""
61
+ skip = page * self._page_size
62
+ raw_items = self._fetch_fn(skip, self._page_size)
63
+
64
+ # Convert dicts to model instances
65
+ items = [self._model_class.model_validate(item) for item in raw_items]
66
+
67
+ # Check if this is the last page
68
+ if len(items) < self._page_size:
69
+ self._exhausted = True
70
+
71
+ return items
72
+
73
+ def __iter__(self) -> Iterator[T]:
74
+ """Return iterator (resets state for fresh iteration)."""
75
+ self._reset()
76
+ return self
77
+
78
+ def __next__(self) -> T:
79
+ """Return next item, fetching pages as needed."""
80
+ # If we have items in buffer, return next one
81
+ if self._buffer:
82
+ return self._buffer.pop(0)
83
+
84
+ # If exhausted, stop iteration
85
+ if self._exhausted:
86
+ raise StopIteration
87
+
88
+ # Fetch next page
89
+ self._started = True
90
+ page_items = self._fetch_page(self._current_page)
91
+ self._current_page += 1
92
+
93
+ if not page_items:
94
+ self._exhausted = True
95
+ raise StopIteration
96
+
97
+ # Buffer all but first item
98
+ self._buffer = page_items[1:]
99
+ return page_items[0]
100
+
101
+ def first_page(self) -> list[T]:
102
+ """Get just the first page of results.
103
+
104
+ Returns:
105
+ List of items from the first page
106
+ """
107
+ return self._fetch_page(0)
108
+
109
+ def all(self) -> list[T]:
110
+ """Fetch all pages and return as a single list.
111
+
112
+ Warning: This loads all items into memory.
113
+
114
+ Returns:
115
+ List of all items across all pages
116
+ """
117
+ return list(self)
118
+
119
+ def take(self, n: int) -> list[T]:
120
+ """Get the first n items (may span multiple pages).
121
+
122
+ Args:
123
+ n: Maximum number of items to return
124
+
125
+ Returns:
126
+ List of up to n items
127
+ """
128
+ result: list[T] = []
129
+ for item in self:
130
+ result.append(item)
131
+ if len(result) >= n:
132
+ break
133
+ return result