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.
- syfthub_sdk/__init__.py +152 -0
- syfthub_sdk/_http.py +327 -0
- syfthub_sdk/_pagination.py +133 -0
- syfthub_sdk/accounting.py +611 -0
- syfthub_sdk/auth.py +371 -0
- syfthub_sdk/chat.py +811 -0
- syfthub_sdk/client.py +311 -0
- syfthub_sdk/exceptions.py +255 -0
- syfthub_sdk/hub.py +239 -0
- syfthub_sdk/models.py +424 -0
- syfthub_sdk/my_endpoints.py +330 -0
- syfthub_sdk/py.typed +0 -0
- syfthub_sdk/syftai.py +389 -0
- syfthub_sdk/users.py +138 -0
- syfthub_sdk-0.1.0.dist-info/METADATA +233 -0
- syfthub_sdk-0.1.0.dist-info/RECORD +17 -0
- syfthub_sdk-0.1.0.dist-info/WHEEL +4 -0
syfthub_sdk/__init__.py
ADDED
|
@@ -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
|