verda 0.1.0__py3-none-any.whl → 0.2.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,3 @@
1
+ from .inference_client import InferenceClient, InferenceResponse
2
+
3
+ __all__ = ['InferenceClient', 'InferenceResponse']
@@ -0,0 +1,525 @@
1
+ from collections.abc import Generator
2
+ from dataclasses import dataclass
3
+ from enum import Enum
4
+ from typing import Any
5
+ from urllib.parse import urlparse
6
+
7
+ import requests
8
+ from dataclasses_json import Undefined, dataclass_json # type: ignore
9
+ from requests.structures import CaseInsensitiveDict
10
+
11
+
12
+ class InferenceClientError(Exception):
13
+ """Base exception for InferenceClient errors."""
14
+
15
+ pass
16
+
17
+
18
+ class AsyncStatus(str, Enum):
19
+ """Async status."""
20
+
21
+ Initialized = 'Initialized'
22
+ Queue = 'Queue'
23
+ Inference = 'Inference'
24
+ Completed = 'Completed'
25
+
26
+
27
+ @dataclass_json(undefined=Undefined.EXCLUDE)
28
+ @dataclass
29
+ class InferenceResponse:
30
+ """Inference response."""
31
+
32
+ headers: CaseInsensitiveDict[str]
33
+ status_code: int
34
+ status_text: str
35
+ _original_response: requests.Response
36
+ _stream: bool = False
37
+
38
+ def _is_stream_response(self, headers: CaseInsensitiveDict[str]) -> bool:
39
+ """Check if the response headers indicate a streaming response.
40
+
41
+ Args:
42
+ headers: The response headers to check
43
+
44
+ Returns:
45
+ bool: True if the response is likely a stream, False otherwise
46
+ """
47
+ # Standard chunked transfer encoding
48
+ is_chunked_transfer = headers.get('Transfer-Encoding', '').lower() == 'chunked'
49
+ # Server-Sent Events content type
50
+ is_event_stream = headers.get('Content-Type', '').lower() == 'text/event-stream'
51
+ # NDJSON
52
+ is_ndjson = headers.get('Content-Type', '').lower() == 'application/x-ndjson'
53
+ # Stream JSON
54
+ is_stream_json = headers.get('Content-Type', '').lower() == 'application/stream+json'
55
+ # Keep-alive
56
+ is_keep_alive = headers.get('Connection', '').lower() == 'keep-alive'
57
+ # No content length
58
+ has_no_content_length = 'Content-Length' not in headers
59
+
60
+ # No Content-Length with keep-alive often suggests streaming (though not definitive)
61
+ is_keep_alive_and_no_content_length = is_keep_alive and has_no_content_length
62
+
63
+ return (
64
+ self._stream
65
+ or is_chunked_transfer
66
+ or is_event_stream
67
+ or is_ndjson
68
+ or is_stream_json
69
+ or is_keep_alive_and_no_content_length
70
+ )
71
+
72
+ def output(self, is_text: bool = False) -> Any:
73
+ """Get response output as a string or object."""
74
+ try:
75
+ if is_text:
76
+ return self._original_response.text
77
+ return self._original_response.json()
78
+ except Exception as e:
79
+ # if the response is a stream (check headers), raise relevant error
80
+ if self._is_stream_response(self._original_response.headers):
81
+ raise InferenceClientError(
82
+ 'Response might be a stream, use the stream method instead'
83
+ ) from e
84
+ raise InferenceClientError(f'Failed to parse response as JSON: {e!s}') from e
85
+
86
+ def stream(self, chunk_size: int = 512, as_text: bool = True) -> Generator[Any, None, None]:
87
+ """Stream the response content.
88
+
89
+ Args:
90
+ chunk_size: Size of chunks to stream, in bytes
91
+ as_text: If True, stream as text using iter_lines. If False, stream as binary using iter_content.
92
+
93
+ Returns:
94
+ Generator yielding chunks of the response
95
+ """
96
+ if as_text:
97
+ for chunk in self._original_response.iter_lines(chunk_size=chunk_size):
98
+ if chunk:
99
+ yield chunk
100
+ else:
101
+ for chunk in self._original_response.iter_content(chunk_size=chunk_size):
102
+ if chunk:
103
+ yield chunk
104
+
105
+
106
+ class InferenceClient:
107
+ """Inference client."""
108
+
109
+ def __init__(
110
+ self, inference_key: str, endpoint_base_url: str, timeout_seconds: int = 60 * 5
111
+ ) -> None:
112
+ """Initialize the InferenceClient.
113
+
114
+ Args:
115
+ inference_key: The authentication key for the API
116
+ endpoint_base_url: The base URL for the API
117
+ timeout_seconds: Request timeout in seconds
118
+
119
+ Raises:
120
+ InferenceClientError: If the parameters are invalid
121
+ """
122
+ if not inference_key:
123
+ raise InferenceClientError('inference_key cannot be empty')
124
+
125
+ parsed_url = urlparse(endpoint_base_url)
126
+ if not parsed_url.scheme or not parsed_url.netloc:
127
+ raise InferenceClientError('endpoint_base_url must be a valid URL')
128
+
129
+ self.inference_key = inference_key
130
+ self.endpoint_base_url = endpoint_base_url.rstrip('/')
131
+ self.base_domain = self.endpoint_base_url[: self.endpoint_base_url.rindex('/')]
132
+ self.deployment_name = self.endpoint_base_url[self.endpoint_base_url.rindex('/') + 1 :]
133
+ self.timeout_seconds = timeout_seconds
134
+ self._session = requests.Session()
135
+ self._global_headers = {
136
+ 'Authorization': f'Bearer {inference_key}',
137
+ 'Content-Type': 'application/json',
138
+ }
139
+
140
+ def __enter__(self):
141
+ return self
142
+
143
+ def __exit__(self, exc_type, exc_val, exc_tb):
144
+ self._session.close()
145
+
146
+ @property
147
+ def global_headers(self) -> dict[str, str]:
148
+ """Get the current global headers that will be used for all requests.
149
+
150
+ Returns:
151
+ Dictionary of current global headers
152
+ """
153
+ return self._global_headers.copy()
154
+
155
+ def set_global_header(self, key: str, value: str) -> None:
156
+ """Set or update a global header that will be used for all requests.
157
+
158
+ Args:
159
+ key: Header name
160
+ value: Header value
161
+ """
162
+ self._global_headers[key] = value
163
+
164
+ def set_global_headers(self, headers: dict[str, str]) -> None:
165
+ """Set multiple global headers at once that will be used for all requests.
166
+
167
+ Args:
168
+ headers: Dictionary of headers to set globally
169
+ """
170
+ self._global_headers.update(headers)
171
+
172
+ def remove_global_header(self, key: str) -> None:
173
+ """Remove a global header.
174
+
175
+ Args:
176
+ key: Header name to remove from global headers
177
+ """
178
+ if key in self._global_headers:
179
+ del self._global_headers[key]
180
+
181
+ def _build_url(self, path: str) -> str:
182
+ """Construct the full URL by joining the base URL with the path."""
183
+ return f'{self.endpoint_base_url}/{path.lstrip("/")}'
184
+
185
+ def _build_request_headers(
186
+ self, request_headers: dict[str, str] | None = None
187
+ ) -> dict[str, str]:
188
+ """Build the final headers by merging global headers with request-specific headers.
189
+
190
+ Args:
191
+ request_headers: Optional headers specific to this request
192
+
193
+ Returns:
194
+ Merged headers dictionary
195
+ """
196
+ headers = self._global_headers.copy()
197
+ if request_headers:
198
+ headers.update(request_headers)
199
+ return headers
200
+
201
+ def _make_request(self, method: str, path: str, **kwargs) -> requests.Response:
202
+ """Make an HTTP request with error handling.
203
+
204
+ Args:
205
+ method: HTTP method to use
206
+ path: API endpoint path
207
+ **kwargs: Additional arguments to pass to the request
208
+
209
+ Returns:
210
+ Response object from the request
211
+
212
+ Raises:
213
+ InferenceClientError: If the request fails
214
+ """
215
+ timeout = kwargs.pop('timeout_seconds', self.timeout_seconds)
216
+ try:
217
+ response = self._session.request(
218
+ method=method,
219
+ url=self._build_url(path),
220
+ headers=self._build_request_headers(kwargs.pop('headers', None)),
221
+ timeout=timeout,
222
+ **kwargs,
223
+ )
224
+ response.raise_for_status()
225
+ return response
226
+ except requests.exceptions.Timeout as e:
227
+ raise InferenceClientError(
228
+ f'Request to {path} timed out after {timeout} seconds'
229
+ ) from e
230
+ except requests.exceptions.RequestException as e:
231
+ raise InferenceClientError(f'Request to {path} failed: {e!s}') from e
232
+
233
+ def run_sync(
234
+ self,
235
+ data: dict[str, Any],
236
+ path: str = '',
237
+ timeout_seconds: int = 60 * 5,
238
+ headers: dict[str, str] | None = None,
239
+ http_method: str = 'POST',
240
+ stream: bool = False,
241
+ ):
242
+ """Make a synchronous request to the inference endpoint.
243
+
244
+ Args:
245
+ data: The data payload to send with the request
246
+ path: API endpoint path. Defaults to empty string.
247
+ timeout_seconds: Request timeout in seconds. Defaults to 5 minutes.
248
+ headers: Optional headers to include in the request
249
+ http_method: HTTP method to use. Defaults to "POST".
250
+ stream: Whether to stream the response. Defaults to False.
251
+
252
+ Returns:
253
+ InferenceResponse: Object containing the response data.
254
+
255
+ Raises:
256
+ InferenceClientError: If the request fails
257
+ """
258
+ response = self._make_request(
259
+ http_method,
260
+ path,
261
+ json=data,
262
+ timeout_seconds=timeout_seconds,
263
+ headers=headers,
264
+ stream=stream,
265
+ )
266
+
267
+ return InferenceResponse(
268
+ headers=response.headers,
269
+ status_code=response.status_code,
270
+ status_text=response.reason,
271
+ _original_response=response,
272
+ )
273
+
274
+ def run(
275
+ self,
276
+ data: dict[str, Any],
277
+ path: str = '',
278
+ timeout_seconds: int = 60 * 5,
279
+ headers: dict[str, str] | None = None,
280
+ http_method: str = 'POST',
281
+ no_response: bool = False,
282
+ ):
283
+ """Make an asynchronous request to the inference endpoint.
284
+
285
+ Args:
286
+ data: The data payload to send with the request
287
+ path: API endpoint path. Defaults to empty string.
288
+ timeout_seconds: Request timeout in seconds. Defaults to 5 minutes.
289
+ headers: Optional headers to include in the request
290
+ http_method: HTTP method to use. Defaults to "POST".
291
+ no_response: If True, don't wait for response. Defaults to False.
292
+
293
+ Returns:
294
+ AsyncInferenceExecution: Object to track the async execution status.
295
+ If no_response is True, returns None.
296
+
297
+ Raises:
298
+ InferenceClientError: If the request fails
299
+ """
300
+ # Add relevant headers to the request, to indicate that the request is async
301
+ headers = headers or {}
302
+ if no_response:
303
+ # If no_response is True, use the "Prefer: respond-async-proxy" header to run async and don't wait for the response
304
+ headers['Prefer'] = 'respond-async-proxy'
305
+ self._make_request(
306
+ http_method,
307
+ path,
308
+ json=data,
309
+ timeout_seconds=timeout_seconds,
310
+ headers=headers,
311
+ )
312
+ return
313
+ # Add the "Prefer: respond-async" header to the request, to run async and wait for the response
314
+ headers['Prefer'] = 'respond-async'
315
+
316
+ response = self._make_request(
317
+ http_method,
318
+ path,
319
+ json=data,
320
+ timeout_seconds=timeout_seconds,
321
+ headers=headers,
322
+ )
323
+
324
+ result = response.json()
325
+ execution_id = result['Id']
326
+
327
+ return AsyncInferenceExecution(self, execution_id, AsyncStatus.Initialized)
328
+
329
+ def get(
330
+ self,
331
+ path: str,
332
+ params: dict[str, Any] | None = None,
333
+ headers: dict[str, str] | None = None,
334
+ timeout_seconds: int | None = None,
335
+ ) -> requests.Response:
336
+ """Make GET request."""
337
+ return self._make_request(
338
+ 'GET', path, params=params, headers=headers, timeout_seconds=timeout_seconds
339
+ )
340
+
341
+ def post(
342
+ self,
343
+ path: str,
344
+ json: dict[str, Any] | None = None,
345
+ data: str | dict[str, Any] | None = None,
346
+ params: dict[str, Any] | None = None,
347
+ headers: dict[str, str] | None = None,
348
+ timeout_seconds: int | None = None,
349
+ ) -> requests.Response:
350
+ """Make POST request."""
351
+ return self._make_request(
352
+ 'POST',
353
+ path,
354
+ json=json,
355
+ data=data,
356
+ params=params,
357
+ headers=headers,
358
+ timeout_seconds=timeout_seconds,
359
+ )
360
+
361
+ def put(
362
+ self,
363
+ path: str,
364
+ json: dict[str, Any] | None = None,
365
+ data: str | dict[str, Any] | None = None,
366
+ params: dict[str, Any] | None = None,
367
+ headers: dict[str, str] | None = None,
368
+ timeout_seconds: int | None = None,
369
+ ) -> requests.Response:
370
+ """Make PUT request."""
371
+ return self._make_request(
372
+ 'PUT',
373
+ path,
374
+ json=json,
375
+ data=data,
376
+ params=params,
377
+ headers=headers,
378
+ timeout_seconds=timeout_seconds,
379
+ )
380
+
381
+ def delete(
382
+ self,
383
+ path: str,
384
+ params: dict[str, Any] | None = None,
385
+ headers: dict[str, str] | None = None,
386
+ timeout_seconds: int | None = None,
387
+ ) -> requests.Response:
388
+ """Make DELETE request."""
389
+ return self._make_request(
390
+ 'DELETE',
391
+ path,
392
+ params=params,
393
+ headers=headers,
394
+ timeout_seconds=timeout_seconds,
395
+ )
396
+
397
+ def patch(
398
+ self,
399
+ path: str,
400
+ json: dict[str, Any] | None = None,
401
+ data: str | dict[str, Any] | None = None,
402
+ params: dict[str, Any] | None = None,
403
+ headers: dict[str, str] | None = None,
404
+ timeout_seconds: int | None = None,
405
+ ) -> requests.Response:
406
+ """Make PATCH request."""
407
+ return self._make_request(
408
+ 'PATCH',
409
+ path,
410
+ json=json,
411
+ data=data,
412
+ params=params,
413
+ headers=headers,
414
+ timeout_seconds=timeout_seconds,
415
+ )
416
+
417
+ def head(
418
+ self,
419
+ path: str,
420
+ params: dict[str, Any] | None = None,
421
+ headers: dict[str, str] | None = None,
422
+ timeout_seconds: int | None = None,
423
+ ) -> requests.Response:
424
+ """Make HEAD request."""
425
+ return self._make_request(
426
+ 'HEAD',
427
+ path,
428
+ params=params,
429
+ headers=headers,
430
+ timeout_seconds=timeout_seconds,
431
+ )
432
+
433
+ def options(
434
+ self,
435
+ path: str,
436
+ params: dict[str, Any] | None = None,
437
+ headers: dict[str, str] | None = None,
438
+ timeout_seconds: int | None = None,
439
+ ) -> requests.Response:
440
+ """Make OPTIONS request."""
441
+ return self._make_request(
442
+ 'OPTIONS',
443
+ path,
444
+ params=params,
445
+ headers=headers,
446
+ timeout_seconds=timeout_seconds,
447
+ )
448
+
449
+ def health(self, healthcheck_path: str = '/health') -> requests.Response:
450
+ """Check the health status of the API.
451
+
452
+ Returns:
453
+ requests.Response: The response from the health check
454
+
455
+ Raises:
456
+ InferenceClientError: If the health check fails
457
+ """
458
+ try:
459
+ return self.get(healthcheck_path)
460
+ except InferenceClientError as e:
461
+ raise InferenceClientError(f'Health check failed: {e!s}') from e
462
+
463
+
464
+ @dataclass_json(undefined=Undefined.EXCLUDE)
465
+ @dataclass
466
+ class AsyncInferenceExecution:
467
+ """Async inference execution."""
468
+
469
+ _inference_client: 'InferenceClient'
470
+ id: str
471
+ _status: AsyncStatus
472
+ INFERENCE_ID_HEADER = 'X-Inference-Id'
473
+
474
+ def status(self) -> AsyncStatus:
475
+ """Get the current stored status of the async inference execution. Only the status value type.
476
+
477
+ Returns:
478
+ AsyncStatus: The status object
479
+ """
480
+ return self._status
481
+
482
+ def status_json(self) -> dict[str, Any]:
483
+ """Get the current status of the async inference execution. Return the status json.
484
+
485
+ Returns:
486
+ dict[str, Any]: The status response containing the execution status and other metadata
487
+ """
488
+ url = (
489
+ f'{self._inference_client.base_domain}/status/{self._inference_client.deployment_name}'
490
+ )
491
+ response = self._inference_client._session.get(
492
+ url,
493
+ headers=self._inference_client._build_request_headers(
494
+ {self.INFERENCE_ID_HEADER: self.id}
495
+ ),
496
+ )
497
+
498
+ response_json = response.json()
499
+ self._status = AsyncStatus(response_json['Status'])
500
+
501
+ return response_json
502
+
503
+ def result(self) -> dict[str, Any]:
504
+ """Get the results of the async inference execution.
505
+
506
+ Returns:
507
+ dict[str, Any]: The results of the inference execution
508
+ """
509
+ url = (
510
+ f'{self._inference_client.base_domain}/result/{self._inference_client.deployment_name}'
511
+ )
512
+ response = self._inference_client._session.get(
513
+ url,
514
+ headers=self._inference_client._build_request_headers(
515
+ {self.INFERENCE_ID_HEADER: self.id}
516
+ ),
517
+ )
518
+
519
+ if response.headers['Content-Type'] == 'application/json':
520
+ return response.json()
521
+ else:
522
+ return {'result': response.text}
523
+
524
+ # alias for get_results
525
+ output = result
verda/__init__.py CHANGED
@@ -1,2 +1,22 @@
1
- def hello() -> str:
2
- return "Hello from verda!"
1
+ import warnings
2
+
3
+ from verda._version import __version__
4
+ from verda.verda import VerdaClient
5
+
6
+
7
+ class _DataCrunchClientAlias:
8
+ def __call__(self, *args, **kwargs):
9
+ warnings.warn(
10
+ 'DataCrunchClient is deprecated; use VerdaClient instead.',
11
+ DeprecationWarning,
12
+ stacklevel=2,
13
+ )
14
+ return VerdaClient(*args, **kwargs)
15
+
16
+
17
+ # creates a callable that behaves like the class
18
+ DataCrunchClient = _DataCrunchClientAlias()
19
+ DataCrunchClient.__name__ = 'DataCrunchClient'
20
+ DataCrunchClient.__doc__ = VerdaClient.__doc__
21
+
22
+ __all__ = ['DataCrunchClient', 'VerdaClient', '__version__']
verda/_version.py ADDED
@@ -0,0 +1,6 @@
1
+ try:
2
+ from importlib.metadata import version
3
+
4
+ __version__ = version('verda')
5
+ except Exception:
6
+ __version__ = '0.0.0+dev' # fallback for development
@@ -0,0 +1,106 @@
1
+ import time
2
+
3
+ import requests
4
+
5
+ from verda.http_client.http_client import handle_error
6
+
7
+ TOKEN_ENDPOINT = '/oauth2/token'
8
+
9
+ CLIENT_CREDENTIALS = 'client_credentials'
10
+ REFRESH_TOKEN = 'refresh_token'
11
+
12
+
13
+ class AuthenticationService:
14
+ """A service for client authentication."""
15
+
16
+ def __init__(self, client_id: str, client_secret: str, base_url: str) -> None:
17
+ self._base_url = base_url
18
+ self._client_id = client_id
19
+ self._client_secret = client_secret
20
+
21
+ def authenticate(self) -> dict:
22
+ """Authenticate the client and store the access & refresh tokens.
23
+
24
+ returns an authentication data dictionary with the following schema:
25
+ {
26
+ "access_token": token str,
27
+ "refresh_token": token str,
28
+ "scope": scope str,
29
+ "token_type": token type str,
30
+ "expires_in": duration until expires in seconds
31
+ }
32
+
33
+ :return: authentication data (tokens, scope, token type, expires in)
34
+ :rtype: dict
35
+ """
36
+ url = self._base_url + TOKEN_ENDPOINT
37
+ payload = {
38
+ 'grant_type': CLIENT_CREDENTIALS,
39
+ 'client_id': self._client_id,
40
+ 'client_secret': self._client_secret,
41
+ }
42
+
43
+ response = requests.post(url, json=payload, headers=self._generate_headers())
44
+ handle_error(response)
45
+
46
+ auth_data = response.json()
47
+
48
+ self._access_token = auth_data['access_token']
49
+ self._refresh_token = auth_data['refresh_token']
50
+ self._scope = auth_data['scope']
51
+ self._token_type = auth_data['token_type']
52
+ self._expires_at = time.time() + auth_data['expires_in']
53
+
54
+ return auth_data
55
+
56
+ def refresh(self) -> dict:
57
+ """Authenticate the client using the refresh token - refresh the access token.
58
+
59
+ updates the object's tokens, and:
60
+ returns an authentication data dictionary with the following schema:
61
+ {
62
+ "access_token": token str,
63
+ "refresh_token": token str,
64
+ "scope": scope str,
65
+ "token_type": token type str,
66
+ "expires_in": duration until expires in seconds
67
+ }
68
+
69
+ :return: authentication data (tokens, scope, token type, expires in)
70
+ :rtype: dict
71
+ """
72
+ url = self._base_url + TOKEN_ENDPOINT
73
+
74
+ payload = {'grant_type': REFRESH_TOKEN, 'refresh_token': self._refresh_token}
75
+
76
+ response = requests.post(url, json=payload, headers=self._generate_headers())
77
+
78
+ # if refresh token is also expired, authenticate again:
79
+ if response.status_code == 401 or response.status_code == 400:
80
+ return self.authenticate()
81
+ else:
82
+ handle_error(response)
83
+
84
+ auth_data = response.json()
85
+
86
+ self._access_token = auth_data['access_token']
87
+ self._refresh_token = auth_data['refresh_token']
88
+ self._scope = auth_data['scope']
89
+ self._token_type = auth_data['token_type']
90
+ self._expires_at = time.time() + auth_data['expires_in']
91
+
92
+ return auth_data
93
+
94
+ def _generate_headers(self):
95
+ # get the first 10 chars of the client id
96
+ client_id_truncated = self._client_id[:10]
97
+ headers = {'User-Agent': 'datacrunch-python-' + client_id_truncated}
98
+ return headers
99
+
100
+ def is_expired(self) -> bool:
101
+ """Returns true if the access token is expired.
102
+
103
+ :return: True if the access token is expired, otherwise False.
104
+ :rtype: bool
105
+ """
106
+ return time.time() >= self._expires_at
File without changes