snipget-client 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.
- snipget/__init__.py +41 -0
- snipget/_client.py +373 -0
- snipget/_exceptions.py +145 -0
- snipget/_response.py +101 -0
- snipget/_version.py +1 -0
- snipget/py.typed +0 -0
- snipget_client-0.1.0.dist-info/METADATA +170 -0
- snipget_client-0.1.0.dist-info/RECORD +10 -0
- snipget_client-0.1.0.dist-info/WHEEL +4 -0
- snipget_client-0.1.0.dist-info/licenses/LICENSE +21 -0
snipget/__init__.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Official Python client for the Snipget API.
|
|
2
|
+
|
|
3
|
+
Snipget is a hosted utility API for AI agents and developers: data
|
|
4
|
+
normalization, parsing, validation, and classification. This package is a
|
|
5
|
+
thin HTTP wrapper around it — the per-endpoint contract lives in the
|
|
6
|
+
OpenAPI spec at https://api.snipget.ai/openapi.json.
|
|
7
|
+
|
|
8
|
+
from snipget import Client
|
|
9
|
+
|
|
10
|
+
client = Client(api_key="...") # or set SNIPGET_API_KEY
|
|
11
|
+
resp = client.call("/healthcare/npi/validate", {"npi": "1234567893"})
|
|
12
|
+
print(resp.result, resp.confidence, resp.meta.request_id)
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from snipget._client import AsyncClient, Client
|
|
16
|
+
from snipget._exceptions import (
|
|
17
|
+
APIError,
|
|
18
|
+
AuthenticationError,
|
|
19
|
+
InvalidRequestError,
|
|
20
|
+
MaintenanceError,
|
|
21
|
+
QuotaExceededError,
|
|
22
|
+
RateLimitError,
|
|
23
|
+
SnipgetError,
|
|
24
|
+
)
|
|
25
|
+
from snipget._response import ResponseMeta, SnipgetResponse
|
|
26
|
+
from snipget._version import __version__
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"APIError",
|
|
30
|
+
"AsyncClient",
|
|
31
|
+
"AuthenticationError",
|
|
32
|
+
"Client",
|
|
33
|
+
"InvalidRequestError",
|
|
34
|
+
"MaintenanceError",
|
|
35
|
+
"QuotaExceededError",
|
|
36
|
+
"RateLimitError",
|
|
37
|
+
"ResponseMeta",
|
|
38
|
+
"SnipgetError",
|
|
39
|
+
"SnipgetResponse",
|
|
40
|
+
"__version__",
|
|
41
|
+
]
|
snipget/_client.py
ADDED
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
"""Sync and async HTTP clients for the Snipget API.
|
|
2
|
+
|
|
3
|
+
This module is deliberately a *thin* transport wrapper: it injects auth
|
|
4
|
+
headers, retries transient failures, and converts the JSON response
|
|
5
|
+
envelope into :class:`SnipgetResponse` / typed exceptions. It contains
|
|
6
|
+
zero business logic by design — the hosted API is the product, and the
|
|
7
|
+
OpenAPI spec at https://api.snipget.ai/openapi.json is the per-endpoint
|
|
8
|
+
contract.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import asyncio
|
|
14
|
+
import os
|
|
15
|
+
import random
|
|
16
|
+
import time
|
|
17
|
+
from types import TracebackType
|
|
18
|
+
from typing import Any, Literal
|
|
19
|
+
|
|
20
|
+
import httpx
|
|
21
|
+
|
|
22
|
+
from snipget._exceptions import (
|
|
23
|
+
APIError,
|
|
24
|
+
AuthenticationError,
|
|
25
|
+
InvalidRequestError,
|
|
26
|
+
MaintenanceError,
|
|
27
|
+
QuotaExceededError,
|
|
28
|
+
RateLimitError,
|
|
29
|
+
SnipgetError,
|
|
30
|
+
)
|
|
31
|
+
from snipget._response import SnipgetResponse
|
|
32
|
+
from snipget._version import __version__
|
|
33
|
+
|
|
34
|
+
__all__ = ["AsyncClient", "Client"]
|
|
35
|
+
|
|
36
|
+
DEFAULT_BASE_URL = "https://api.snipget.ai"
|
|
37
|
+
DEFAULT_TIMEOUT = 30.0
|
|
38
|
+
DEFAULT_MAX_RETRIES = 2
|
|
39
|
+
|
|
40
|
+
_ENV_API_KEY = "SNIPGET_API_KEY"
|
|
41
|
+
_USER_AGENT = f"snipget-python/{__version__}"
|
|
42
|
+
|
|
43
|
+
# Retry tuning. Exponential backoff: 0.5s, 1s, 2s, ... capped at 8s, plus
|
|
44
|
+
# up to 25% jitter so synchronized callers don't stampede on recovery.
|
|
45
|
+
_BACKOFF_BASE = 0.5
|
|
46
|
+
_BACKOFF_CAP = 8.0
|
|
47
|
+
# Never sleep longer than this even if the server's Retry-After asks for
|
|
48
|
+
# more (e.g. the 300s maintenance window) — a blocking 5-minute sleep
|
|
49
|
+
# inside a utility call would be hostile to callers.
|
|
50
|
+
_RETRY_AFTER_CAP = 60.0
|
|
51
|
+
|
|
52
|
+
# Test seam: tests monkeypatch these to assert on sleep behavior without
|
|
53
|
+
# slowing the suite down.
|
|
54
|
+
_sleep = time.sleep
|
|
55
|
+
_async_sleep = asyncio.sleep
|
|
56
|
+
|
|
57
|
+
AuthHeaderStyle = Literal["authorization", "x-api-key"]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _resolve_api_key(api_key: str | None) -> str:
|
|
61
|
+
key = api_key if api_key is not None else os.environ.get(_ENV_API_KEY)
|
|
62
|
+
if not key:
|
|
63
|
+
raise AuthenticationError(
|
|
64
|
+
"No API key provided. Pass api_key=... to the client or set the "
|
|
65
|
+
f"{_ENV_API_KEY} environment variable. Get a key at https://snipget.ai."
|
|
66
|
+
)
|
|
67
|
+
return key
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _build_headers(api_key: str, auth_header: AuthHeaderStyle) -> dict[str, str]:
|
|
71
|
+
headers = {"User-Agent": _USER_AGENT}
|
|
72
|
+
if auth_header == "authorization":
|
|
73
|
+
headers["Authorization"] = f"Bearer {api_key}"
|
|
74
|
+
elif auth_header == "x-api-key":
|
|
75
|
+
headers["X-API-Key"] = api_key
|
|
76
|
+
else:
|
|
77
|
+
raise ValueError(f"auth_header must be 'authorization' or 'x-api-key', got {auth_header!r}")
|
|
78
|
+
return headers
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _resolve_method(method: str | None, payload: dict[str, Any] | None) -> str:
|
|
82
|
+
"""Default to POST when a payload is given, GET otherwise.
|
|
83
|
+
|
|
84
|
+
All 128 utility endpoints are POST; the handful of GET endpoints
|
|
85
|
+
(/, /health, /pricing/tiers, ...) take no payload, so the default does
|
|
86
|
+
the right thing for every path in the spec. ``method`` overrides.
|
|
87
|
+
"""
|
|
88
|
+
if method is not None:
|
|
89
|
+
return method.upper()
|
|
90
|
+
return "POST" if payload is not None else "GET"
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _normalize_path(path: str) -> str:
|
|
94
|
+
return path if path.startswith("/") else f"/{path}"
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _retry_after_seconds(response: httpx.Response, body: dict[str, Any] | None) -> float | None:
|
|
98
|
+
"""Pull the retry hint from ``retry_after_seconds`` (envelope, exact
|
|
99
|
+
float) or the ``Retry-After`` header (integer seconds, rounded up by
|
|
100
|
+
the server)."""
|
|
101
|
+
if isinstance(body, dict):
|
|
102
|
+
value = body.get("retry_after_seconds")
|
|
103
|
+
if isinstance(value, (int, float)) and not isinstance(value, bool):
|
|
104
|
+
return float(value)
|
|
105
|
+
header = response.headers.get("Retry-After")
|
|
106
|
+
if header is not None:
|
|
107
|
+
try:
|
|
108
|
+
return float(header)
|
|
109
|
+
except ValueError:
|
|
110
|
+
return None
|
|
111
|
+
return None
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _parse_success(response: httpx.Response) -> SnipgetResponse:
|
|
115
|
+
try:
|
|
116
|
+
data = response.json()
|
|
117
|
+
except ValueError as exc:
|
|
118
|
+
raise APIError(
|
|
119
|
+
"Expected a JSON envelope but the response body was not valid JSON.",
|
|
120
|
+
http_status=response.status_code,
|
|
121
|
+
) from exc
|
|
122
|
+
if not isinstance(data, dict):
|
|
123
|
+
raise APIError(
|
|
124
|
+
"Expected a JSON envelope object but got a different JSON type.",
|
|
125
|
+
http_status=response.status_code,
|
|
126
|
+
)
|
|
127
|
+
return SnipgetResponse.from_dict(data)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _error_from_response(response: httpx.Response) -> SnipgetError:
|
|
131
|
+
"""Map a non-2xx response onto the typed exception taxonomy."""
|
|
132
|
+
status = response.status_code
|
|
133
|
+
try:
|
|
134
|
+
body = response.json()
|
|
135
|
+
except ValueError:
|
|
136
|
+
body = None
|
|
137
|
+
if not isinstance(body, dict):
|
|
138
|
+
return APIError(
|
|
139
|
+
f"HTTP {status} with a non-JSON body.",
|
|
140
|
+
http_status=status,
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
error_code = body.get("error_code")
|
|
144
|
+
message = body.get("message") or f"HTTP {status}"
|
|
145
|
+
meta = body.get("meta") if isinstance(body.get("meta"), dict) else {}
|
|
146
|
+
common: dict[str, Any] = {
|
|
147
|
+
"error_code": error_code,
|
|
148
|
+
"request_id": meta.get("request_id"),
|
|
149
|
+
"http_status": status,
|
|
150
|
+
"body": body,
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if status == 429:
|
|
154
|
+
if error_code == "QUOTA_EXCEEDED":
|
|
155
|
+
return QuotaExceededError(
|
|
156
|
+
message,
|
|
157
|
+
credit_remaining_usd=meta.get("credit_remaining_usd"),
|
|
158
|
+
**common,
|
|
159
|
+
)
|
|
160
|
+
# RATE_LIMITED (and any future throttle variant on 429).
|
|
161
|
+
return RateLimitError(
|
|
162
|
+
message,
|
|
163
|
+
retry_after=_retry_after_seconds(response, body),
|
|
164
|
+
**common,
|
|
165
|
+
)
|
|
166
|
+
if status == 503 and error_code == "MAINTENANCE_MODE":
|
|
167
|
+
return MaintenanceError(
|
|
168
|
+
message,
|
|
169
|
+
retry_after=_retry_after_seconds(response, body),
|
|
170
|
+
**common,
|
|
171
|
+
)
|
|
172
|
+
if status in (401, 403):
|
|
173
|
+
return AuthenticationError(message, **common)
|
|
174
|
+
if status in (400, 422):
|
|
175
|
+
return InvalidRequestError(message, **common)
|
|
176
|
+
return APIError(message, **common)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _is_retryable(error: SnipgetError) -> bool:
|
|
180
|
+
"""Whether a retry can possibly succeed.
|
|
181
|
+
|
|
182
|
+
Snipget utility calls are pure and idempotent (same input, same
|
|
183
|
+
output, no server-side state mutation), so retrying POSTs is safe.
|
|
184
|
+
|
|
185
|
+
- QUOTA_EXCEEDED never retries: it doesn't lift until the monthly
|
|
186
|
+
reset, a tier upgrade, or an allowance top-up.
|
|
187
|
+
- RATE_LIMITED retries: it's a per-second throttle.
|
|
188
|
+
- 5xx retries (includes MAINTENANCE_MODE, with short backoff).
|
|
189
|
+
- All other 4xx never retry: resending the same bad request can't help.
|
|
190
|
+
"""
|
|
191
|
+
if isinstance(error, QuotaExceededError):
|
|
192
|
+
return False
|
|
193
|
+
if isinstance(error, RateLimitError):
|
|
194
|
+
return True
|
|
195
|
+
return error.http_status is not None and error.http_status >= 500
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _retry_delay(error: SnipgetError | None, attempt: int) -> float:
|
|
199
|
+
"""Seconds to sleep before retry number ``attempt`` (0-based).
|
|
200
|
+
|
|
201
|
+
RATE_LIMITED honors the server's Retry-After (capped). Everything
|
|
202
|
+
else — network errors, 5xx, maintenance — uses exponential backoff
|
|
203
|
+
with jitter; we deliberately do NOT honor maintenance's 300s hint
|
|
204
|
+
here (see MaintenanceError's docstring).
|
|
205
|
+
"""
|
|
206
|
+
if isinstance(error, RateLimitError) and error.retry_after is not None:
|
|
207
|
+
return min(error.retry_after, _RETRY_AFTER_CAP)
|
|
208
|
+
base = min(_BACKOFF_BASE * (2**attempt), _BACKOFF_CAP)
|
|
209
|
+
return base + random.uniform(0.0, base / 4)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
class Client:
|
|
213
|
+
"""Synchronous Snipget API client.
|
|
214
|
+
|
|
215
|
+
Args:
|
|
216
|
+
api_key: Your Snipget API key. Falls back to the
|
|
217
|
+
``SNIPGET_API_KEY`` environment variable when omitted.
|
|
218
|
+
base_url: API origin; override for testing or self-hosted stacks.
|
|
219
|
+
timeout: Per-request timeout in seconds.
|
|
220
|
+
max_retries: How many times to retry retryable failures (network
|
|
221
|
+
errors, RATE_LIMITED, 5xx) on top of the initial attempt.
|
|
222
|
+
auth_header: ``"authorization"`` sends ``Authorization: Bearer <key>``
|
|
223
|
+
(preferred); ``"x-api-key"`` sends ``X-API-Key: <key>``.
|
|
224
|
+
transport: Optional httpx transport (proxies, mocking, ...).
|
|
225
|
+
|
|
226
|
+
Usage:
|
|
227
|
+
>>> from snipget import Client
|
|
228
|
+
>>> client = Client() # reads SNIPGET_API_KEY
|
|
229
|
+
>>> resp = client.call("/healthcare/npi/validate", {"npi": "1234567893"})
|
|
230
|
+
>>> resp.result["is_valid"]
|
|
231
|
+
True
|
|
232
|
+
"""
|
|
233
|
+
|
|
234
|
+
def __init__(
|
|
235
|
+
self,
|
|
236
|
+
api_key: str | None = None,
|
|
237
|
+
*,
|
|
238
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
239
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
240
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
241
|
+
auth_header: AuthHeaderStyle = "authorization",
|
|
242
|
+
transport: httpx.BaseTransport | None = None,
|
|
243
|
+
) -> None:
|
|
244
|
+
self.api_key = _resolve_api_key(api_key)
|
|
245
|
+
self.base_url = base_url.rstrip("/")
|
|
246
|
+
self.max_retries = max_retries
|
|
247
|
+
self._http = httpx.Client(
|
|
248
|
+
base_url=self.base_url,
|
|
249
|
+
timeout=timeout,
|
|
250
|
+
headers=_build_headers(self.api_key, auth_header),
|
|
251
|
+
transport=transport,
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
def call(
|
|
255
|
+
self,
|
|
256
|
+
path: str,
|
|
257
|
+
payload: dict[str, Any] | None = None,
|
|
258
|
+
*,
|
|
259
|
+
method: str | None = None,
|
|
260
|
+
) -> SnipgetResponse:
|
|
261
|
+
"""Call any Snipget endpoint and return the parsed envelope.
|
|
262
|
+
|
|
263
|
+
Args:
|
|
264
|
+
path: Endpoint path, e.g. ``"/healthcare/npi/validate"``.
|
|
265
|
+
payload: JSON body. When given, the request defaults to POST.
|
|
266
|
+
method: Explicit HTTP method override.
|
|
267
|
+
|
|
268
|
+
Raises:
|
|
269
|
+
SnipgetError subclasses mapped from the error envelope.
|
|
270
|
+
"""
|
|
271
|
+
resolved_method = _resolve_method(method, payload)
|
|
272
|
+
request_path = _normalize_path(path)
|
|
273
|
+
for attempt in range(self.max_retries + 1):
|
|
274
|
+
try:
|
|
275
|
+
response = self._http.request(resolved_method, request_path, json=payload)
|
|
276
|
+
except httpx.TransportError as exc:
|
|
277
|
+
if attempt >= self.max_retries:
|
|
278
|
+
raise APIError(f"Network error calling {request_path}: {exc}") from exc
|
|
279
|
+
_sleep(_retry_delay(None, attempt))
|
|
280
|
+
continue
|
|
281
|
+
if response.is_success:
|
|
282
|
+
return _parse_success(response)
|
|
283
|
+
error = _error_from_response(response)
|
|
284
|
+
if attempt >= self.max_retries or not _is_retryable(error):
|
|
285
|
+
raise error
|
|
286
|
+
_sleep(_retry_delay(error, attempt))
|
|
287
|
+
raise AssertionError("unreachable") # pragma: no cover
|
|
288
|
+
|
|
289
|
+
def close(self) -> None:
|
|
290
|
+
self._http.close()
|
|
291
|
+
|
|
292
|
+
def __enter__(self) -> Client:
|
|
293
|
+
return self
|
|
294
|
+
|
|
295
|
+
def __exit__(
|
|
296
|
+
self,
|
|
297
|
+
exc_type: type[BaseException] | None,
|
|
298
|
+
exc: BaseException | None,
|
|
299
|
+
tb: TracebackType | None,
|
|
300
|
+
) -> None:
|
|
301
|
+
self.close()
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
class AsyncClient:
|
|
305
|
+
"""Asynchronous Snipget API client. Same surface as :class:`Client`.
|
|
306
|
+
|
|
307
|
+
Usage:
|
|
308
|
+
>>> from snipget import AsyncClient
|
|
309
|
+
>>> async with AsyncClient() as client:
|
|
310
|
+
... resp = await client.call("/healthcare/npi/validate", {"npi": "1234567893"})
|
|
311
|
+
... resp.result["is_valid"]
|
|
312
|
+
True
|
|
313
|
+
"""
|
|
314
|
+
|
|
315
|
+
def __init__(
|
|
316
|
+
self,
|
|
317
|
+
api_key: str | None = None,
|
|
318
|
+
*,
|
|
319
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
320
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
321
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
322
|
+
auth_header: AuthHeaderStyle = "authorization",
|
|
323
|
+
transport: httpx.AsyncBaseTransport | None = None,
|
|
324
|
+
) -> None:
|
|
325
|
+
self.api_key = _resolve_api_key(api_key)
|
|
326
|
+
self.base_url = base_url.rstrip("/")
|
|
327
|
+
self.max_retries = max_retries
|
|
328
|
+
self._http = httpx.AsyncClient(
|
|
329
|
+
base_url=self.base_url,
|
|
330
|
+
timeout=timeout,
|
|
331
|
+
headers=_build_headers(self.api_key, auth_header),
|
|
332
|
+
transport=transport,
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
async def call(
|
|
336
|
+
self,
|
|
337
|
+
path: str,
|
|
338
|
+
payload: dict[str, Any] | None = None,
|
|
339
|
+
*,
|
|
340
|
+
method: str | None = None,
|
|
341
|
+
) -> SnipgetResponse:
|
|
342
|
+
"""Async variant of :meth:`Client.call`."""
|
|
343
|
+
resolved_method = _resolve_method(method, payload)
|
|
344
|
+
request_path = _normalize_path(path)
|
|
345
|
+
for attempt in range(self.max_retries + 1):
|
|
346
|
+
try:
|
|
347
|
+
response = await self._http.request(resolved_method, request_path, json=payload)
|
|
348
|
+
except httpx.TransportError as exc:
|
|
349
|
+
if attempt >= self.max_retries:
|
|
350
|
+
raise APIError(f"Network error calling {request_path}: {exc}") from exc
|
|
351
|
+
await _async_sleep(_retry_delay(None, attempt))
|
|
352
|
+
continue
|
|
353
|
+
if response.is_success:
|
|
354
|
+
return _parse_success(response)
|
|
355
|
+
error = _error_from_response(response)
|
|
356
|
+
if attempt >= self.max_retries or not _is_retryable(error):
|
|
357
|
+
raise error
|
|
358
|
+
await _async_sleep(_retry_delay(error, attempt))
|
|
359
|
+
raise AssertionError("unreachable") # pragma: no cover
|
|
360
|
+
|
|
361
|
+
async def aclose(self) -> None:
|
|
362
|
+
await self._http.aclose()
|
|
363
|
+
|
|
364
|
+
async def __aenter__(self) -> AsyncClient:
|
|
365
|
+
return self
|
|
366
|
+
|
|
367
|
+
async def __aexit__(
|
|
368
|
+
self,
|
|
369
|
+
exc_type: type[BaseException] | None,
|
|
370
|
+
exc: BaseException | None,
|
|
371
|
+
tb: TracebackType | None,
|
|
372
|
+
) -> None:
|
|
373
|
+
await self.aclose()
|
snipget/_exceptions.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""Typed exception taxonomy for the Snipget client.
|
|
2
|
+
|
|
3
|
+
Every exception maps one-to-one onto the API's error envelope:
|
|
4
|
+
|
|
5
|
+
{"status": "error", "error_code": "...", "message": "...", "meta": {...}}
|
|
6
|
+
|
|
7
|
+
The full parsed envelope is always available on ``exc.body`` so callers can
|
|
8
|
+
reach envelope fields the typed attributes don't surface (e.g. the ``details``
|
|
9
|
+
list on 422 validation errors, or ``limit_type`` on quota errors).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"APIError",
|
|
18
|
+
"AuthenticationError",
|
|
19
|
+
"InvalidRequestError",
|
|
20
|
+
"MaintenanceError",
|
|
21
|
+
"QuotaExceededError",
|
|
22
|
+
"RateLimitError",
|
|
23
|
+
"SnipgetError",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class SnipgetError(Exception):
|
|
28
|
+
"""Base class for every error raised by the Snipget client.
|
|
29
|
+
|
|
30
|
+
Attributes:
|
|
31
|
+
message: Human-readable message from the API (or the client).
|
|
32
|
+
error_code: Machine-readable code from the error envelope,
|
|
33
|
+
e.g. ``"INVALID_API_KEY"``. ``None`` when no envelope was
|
|
34
|
+
available (network failure, non-JSON body).
|
|
35
|
+
request_id: The ``meta.request_id`` from the envelope; quote it
|
|
36
|
+
when contacting support.
|
|
37
|
+
http_status: HTTP status code of the response, or ``None`` for
|
|
38
|
+
errors raised before a response existed.
|
|
39
|
+
body: The full parsed error envelope dict, when available.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
def __init__(
|
|
43
|
+
self,
|
|
44
|
+
message: str,
|
|
45
|
+
*,
|
|
46
|
+
error_code: str | None = None,
|
|
47
|
+
request_id: str | None = None,
|
|
48
|
+
http_status: int | None = None,
|
|
49
|
+
body: dict[str, Any] | None = None,
|
|
50
|
+
) -> None:
|
|
51
|
+
super().__init__(message)
|
|
52
|
+
self.message = message
|
|
53
|
+
self.error_code = error_code
|
|
54
|
+
self.request_id = request_id
|
|
55
|
+
self.http_status = http_status
|
|
56
|
+
self.body = body
|
|
57
|
+
|
|
58
|
+
def __str__(self) -> str:
|
|
59
|
+
parts = [self.message]
|
|
60
|
+
if self.error_code is not None:
|
|
61
|
+
parts.append(f"[error_code={self.error_code}]")
|
|
62
|
+
if self.http_status is not None:
|
|
63
|
+
parts.append(f"[http_status={self.http_status}]")
|
|
64
|
+
if self.request_id is not None:
|
|
65
|
+
parts.append(f"[request_id={self.request_id}]")
|
|
66
|
+
return " ".join(parts)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class AuthenticationError(SnipgetError):
|
|
70
|
+
"""401/403 — missing, invalid, or restricted API key.
|
|
71
|
+
|
|
72
|
+
Covers ``MISSING_API_KEY``, ``INVALID_API_KEY``, and ``IP_NOT_ALLOWED``.
|
|
73
|
+
Also raised client-side when no API key can be resolved at all.
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class InvalidRequestError(SnipgetError):
|
|
78
|
+
"""400/422 — the request was rejected before any work was done.
|
|
79
|
+
|
|
80
|
+
``INVALID_INPUT`` (400) or ``INVALID_REQUEST`` (422). For 422s the
|
|
81
|
+
Pydantic field errors are in ``exc.body["details"]``.
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class RateLimitError(SnipgetError):
|
|
86
|
+
"""429 ``RATE_LIMITED`` — per-second throughput throttle.
|
|
87
|
+
|
|
88
|
+
Retryable within seconds; the client retries these automatically,
|
|
89
|
+
honoring ``retry_after``. Distinct from :class:`QuotaExceededError`,
|
|
90
|
+
which does not lift until the next month / an upgrade / a top-up.
|
|
91
|
+
|
|
92
|
+
Attributes:
|
|
93
|
+
retry_after: Seconds to wait before retrying, from the envelope's
|
|
94
|
+
``retry_after_seconds`` (preferred) or the ``Retry-After``
|
|
95
|
+
header. ``None`` if the server sent neither.
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
def __init__(self, message: str, *, retry_after: float | None = None, **kwargs: Any) -> None:
|
|
99
|
+
super().__init__(message, **kwargs)
|
|
100
|
+
self.retry_after = retry_after
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class QuotaExceededError(SnipgetError):
|
|
104
|
+
"""429 ``QUOTA_EXCEEDED`` — monthly quota or prepaid allowance exhausted.
|
|
105
|
+
|
|
106
|
+
NOT retryable: it does not lift until the next UTC calendar month, a
|
|
107
|
+
tier upgrade, or an allowance purchase. The client never retries it.
|
|
108
|
+
``exc.body["limit_type"]`` says which recovery applies
|
|
109
|
+
(``monthly_quota`` / ``included_exhausted`` / ``overage_balance_exhausted``).
|
|
110
|
+
|
|
111
|
+
Attributes:
|
|
112
|
+
credit_remaining_usd: Live prepaid-allowance balance from
|
|
113
|
+
``meta.credit_remaining_usd``, when the server included it.
|
|
114
|
+
"""
|
|
115
|
+
|
|
116
|
+
def __init__(
|
|
117
|
+
self,
|
|
118
|
+
message: str,
|
|
119
|
+
*,
|
|
120
|
+
credit_remaining_usd: float | None = None,
|
|
121
|
+
**kwargs: Any,
|
|
122
|
+
) -> None:
|
|
123
|
+
super().__init__(message, **kwargs)
|
|
124
|
+
self.credit_remaining_usd = credit_remaining_usd
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class MaintenanceError(SnipgetError):
|
|
128
|
+
"""503 ``MAINTENANCE_MODE`` — the API is in a maintenance window.
|
|
129
|
+
|
|
130
|
+
Attributes:
|
|
131
|
+
retry_after: Seconds until the server suggests retrying
|
|
132
|
+
(typically 300). The client's automatic retries use its own
|
|
133
|
+
short backoff instead of sleeping this long; if you see this
|
|
134
|
+
exception, wait ``retry_after`` seconds and call again.
|
|
135
|
+
"""
|
|
136
|
+
|
|
137
|
+
def __init__(self, message: str, *, retry_after: float | None = None, **kwargs: Any) -> None:
|
|
138
|
+
super().__init__(message, **kwargs)
|
|
139
|
+
self.retry_after = retry_after
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class APIError(SnipgetError):
|
|
143
|
+
"""Any other failure: unexpected status codes, 5xx errors, non-JSON
|
|
144
|
+
bodies, and network errors that survived the retry budget
|
|
145
|
+
(``http_status`` is ``None`` for those)."""
|
snipget/_response.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Typed views over the Snipget success envelope.
|
|
2
|
+
|
|
3
|
+
Every Snipget endpoint returns the same JSON envelope:
|
|
4
|
+
|
|
5
|
+
{
|
|
6
|
+
"status": "ok",
|
|
7
|
+
"confidence": 0.92,
|
|
8
|
+
"result": {...},
|
|
9
|
+
"meta": {"version": "...", "elapsed_ms": 3, "cost_units": 1,
|
|
10
|
+
"request_id": "req_...", ...}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
These classes only *carry* that envelope; they never reshape or interpret
|
|
14
|
+
``result``. The per-endpoint result schemas live in the OpenAPI spec at
|
|
15
|
+
https://api.snipget.ai/openapi.json.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
__all__ = ["ResponseMeta", "SnipgetResponse"]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _opt_int(value: Any) -> int | None:
|
|
27
|
+
return value if isinstance(value, int) and not isinstance(value, bool) else None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _opt_float(value: Any) -> float | None:
|
|
31
|
+
if isinstance(value, bool):
|
|
32
|
+
return None
|
|
33
|
+
return float(value) if isinstance(value, (int, float)) else None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class ResponseMeta:
|
|
38
|
+
"""Typed view of the envelope's ``meta`` object.
|
|
39
|
+
|
|
40
|
+
Fields the server didn't send are ``None``. The untouched meta dict is
|
|
41
|
+
available as ``raw`` (so additive server-side fields are never lost).
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
version: str | None = None
|
|
45
|
+
elapsed_ms: int | None = None
|
|
46
|
+
cost_units: int | None = None
|
|
47
|
+
request_id: str | None = None
|
|
48
|
+
trace: list[str] | None = None
|
|
49
|
+
rate_limit_remaining: int | None = None
|
|
50
|
+
rate_limit_reset: int | None = None # unix timestamp
|
|
51
|
+
quota_remaining: int | None = None
|
|
52
|
+
quota_reset: int | None = None # unix timestamp (start of next UTC month)
|
|
53
|
+
credit_remaining_usd: float | None = None
|
|
54
|
+
raw: dict[str, Any] = field(default_factory=dict, repr=False)
|
|
55
|
+
|
|
56
|
+
@classmethod
|
|
57
|
+
def from_dict(cls, data: dict[str, Any]) -> ResponseMeta:
|
|
58
|
+
return cls(
|
|
59
|
+
version=data.get("version"),
|
|
60
|
+
elapsed_ms=_opt_int(data.get("elapsed_ms")),
|
|
61
|
+
cost_units=_opt_int(data.get("cost_units")),
|
|
62
|
+
request_id=data.get("request_id"),
|
|
63
|
+
trace=data.get("trace"),
|
|
64
|
+
rate_limit_remaining=_opt_int(data.get("rate_limit_remaining")),
|
|
65
|
+
rate_limit_reset=_opt_int(data.get("rate_limit_reset")),
|
|
66
|
+
quota_remaining=_opt_int(data.get("quota_remaining")),
|
|
67
|
+
quota_reset=_opt_int(data.get("quota_reset")),
|
|
68
|
+
credit_remaining_usd=_opt_float(data.get("credit_remaining_usd")),
|
|
69
|
+
raw=data,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass(frozen=True)
|
|
74
|
+
class SnipgetResponse:
|
|
75
|
+
"""One parsed success envelope.
|
|
76
|
+
|
|
77
|
+
Attributes:
|
|
78
|
+
status: Always ``"ok"`` for a success envelope.
|
|
79
|
+
confidence: 0.0-1.0 confidence score for the result.
|
|
80
|
+
result: The endpoint-specific payload, exactly as the API sent it.
|
|
81
|
+
meta: Typed metadata (cost_units, request_id, rate-limit and
|
|
82
|
+
quota headroom, ...).
|
|
83
|
+
raw: The full unmodified envelope dict.
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
status: str
|
|
87
|
+
confidence: float
|
|
88
|
+
result: Any
|
|
89
|
+
meta: ResponseMeta
|
|
90
|
+
raw: dict[str, Any] = field(repr=False, default_factory=dict)
|
|
91
|
+
|
|
92
|
+
@classmethod
|
|
93
|
+
def from_dict(cls, data: dict[str, Any]) -> SnipgetResponse:
|
|
94
|
+
meta = data.get("meta")
|
|
95
|
+
return cls(
|
|
96
|
+
status=data.get("status", "ok"),
|
|
97
|
+
confidence=float(data.get("confidence", 0.0)),
|
|
98
|
+
result=data.get("result"),
|
|
99
|
+
meta=ResponseMeta.from_dict(meta if isinstance(meta, dict) else {}),
|
|
100
|
+
raw=data,
|
|
101
|
+
)
|
snipget/_version.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
snipget/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: snipget-client
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Official Python client for the Snipget API: data normalization, parsing, validation, and classification utilities for AI agents.
|
|
5
|
+
Project-URL: Homepage, https://snipget.ai
|
|
6
|
+
Project-URL: Documentation, https://api.snipget.ai/docs
|
|
7
|
+
Project-URL: Repository, https://github.com/snipget/snipget-python
|
|
8
|
+
Author-email: "Snipget Inc." <hello@snipget.ai>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: agent-tools,ai-agents,api,data-normalization,data-validation,healthcare,llm,mcp,npi,parsing
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
21
|
+
Classifier: Typing :: Typed
|
|
22
|
+
Requires-Python: >=3.10
|
|
23
|
+
Requires-Dist: httpx<1,>=0.24.0
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
26
|
+
Requires-Dist: ruff>=0.8; extra == 'dev'
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
|
|
29
|
+
# snipget-client
|
|
30
|
+
|
|
31
|
+
The official Python client for [Snipget](https://snipget.ai), the hosted utility API for AI agents: data normalization, parsing, validation, and classification over plain HTTPS.
|
|
32
|
+
|
|
33
|
+
## What is Snipget
|
|
34
|
+
|
|
35
|
+
Snipget is a hosted, pay-per-call utility API built for AI agents and the developers who build them. It serves 130+ programmatic endpoints for data normalization, parsing, validation, and classification, with particular depth in healthcare data: NPI validation and lookup, DEA numbers, provider taxonomy, credentials, and certifications. Every endpoint is deterministic (no LLM calls inside the API), returns a confidence score, and ships in single-record and batch variants.
|
|
36
|
+
|
|
37
|
+
Snipget is agent-native by design. Agents can discover and call it through the [OpenAPI spec](https://api.snipget.ai/openapi.json) or the MCP server, and every response uses one consistent JSON envelope so a single integration covers the whole catalog. This package is a thin HTTP wrapper around that hosted API; all the actual logic runs server-side, and the [interactive docs](https://api.snipget.ai/docs) are the per-endpoint contract.
|
|
38
|
+
|
|
39
|
+
## Install
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
pip install snipget-client
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Requires Python 3.10+. The only dependency is [httpx](https://www.python-httpx.org/).
|
|
46
|
+
|
|
47
|
+
## Quickstart
|
|
48
|
+
|
|
49
|
+
You need an API key from [snipget.ai](https://snipget.ai). One generic `call()` method reaches every endpoint; pass the path and the JSON payload from the [API docs](https://api.snipget.ai/docs).
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
from snipget import Client
|
|
53
|
+
|
|
54
|
+
client = Client(api_key="YOUR_API_KEY") # or set SNIPGET_API_KEY
|
|
55
|
+
|
|
56
|
+
resp = client.call("/healthcare/npi/validate", {"npi": "1234567893"})
|
|
57
|
+
|
|
58
|
+
print(resp.result)
|
|
59
|
+
# {'npi': 1234567893, 'is_valid': True, 'checksum_valid': True, 'input_was_clean': True}
|
|
60
|
+
print(resp.confidence) # 1.0
|
|
61
|
+
print(resp.meta.cost_units) # 1
|
|
62
|
+
print(resp.meta.request_id) # 'req_...'
|
|
63
|
+
|
|
64
|
+
# Batch variants exist for every utility:
|
|
65
|
+
resp = client.call(
|
|
66
|
+
"/healthcare/npi/validate/batch",
|
|
67
|
+
{"items": ["1234567893", "1234567890"]},
|
|
68
|
+
)
|
|
69
|
+
print(resp.result["summary"]) # {'total': 2, 'valid': 1, 'invalid': 1}
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Async, same surface:
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
import asyncio
|
|
76
|
+
from snipget import AsyncClient
|
|
77
|
+
|
|
78
|
+
async def main():
|
|
79
|
+
async with AsyncClient() as client: # reads SNIPGET_API_KEY
|
|
80
|
+
resp = await client.call(
|
|
81
|
+
"/common/phone/validate",
|
|
82
|
+
{"value": "(415) 555-0132", "country_hint": "US"},
|
|
83
|
+
)
|
|
84
|
+
print(resp.result)
|
|
85
|
+
|
|
86
|
+
asyncio.run(main())
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
`call()` defaults to `POST` when a payload is given and `GET` otherwise, which matches every endpoint in the spec; pass `method=` to override.
|
|
90
|
+
|
|
91
|
+
## Authentication
|
|
92
|
+
|
|
93
|
+
Get an API key at [snipget.ai](https://snipget.ai). The client resolves the key in this order:
|
|
94
|
+
|
|
95
|
+
1. `Client(api_key="...")`
|
|
96
|
+
2. The `SNIPGET_API_KEY` environment variable
|
|
97
|
+
|
|
98
|
+
By default the key is sent as `Authorization: Bearer <key>`. The API also accepts an `X-API-Key` header; opt in with `Client(auth_header="x-api-key")`.
|
|
99
|
+
|
|
100
|
+
## Error handling
|
|
101
|
+
|
|
102
|
+
Every API error is raised as a typed exception. All of them subclass `SnipgetError` and carry `error_code`, `message`, `request_id`, `http_status`, and the full parsed envelope as `body`.
|
|
103
|
+
|
|
104
|
+
```python
|
|
105
|
+
import snipget
|
|
106
|
+
|
|
107
|
+
client = snipget.Client()
|
|
108
|
+
|
|
109
|
+
try:
|
|
110
|
+
resp = client.call("/healthcare/npi/validate", {"npi": "1234567893"})
|
|
111
|
+
except snipget.AuthenticationError as e:
|
|
112
|
+
print("Check your API key:", e.error_code) # 401/403
|
|
113
|
+
except snipget.InvalidRequestError as e:
|
|
114
|
+
print("Bad request:", e.body.get("details")) # 400/422
|
|
115
|
+
except snipget.RateLimitError as e:
|
|
116
|
+
print("Throttled; retry in", e.retry_after, "seconds") # 429 RATE_LIMITED
|
|
117
|
+
except snipget.QuotaExceededError as e:
|
|
118
|
+
print("Out of monthly capacity:", e.body.get("limit_type"))
|
|
119
|
+
print("Allowance left (USD):", e.credit_remaining_usd) # 429 QUOTA_EXCEEDED
|
|
120
|
+
except snipget.MaintenanceError as e:
|
|
121
|
+
print("Maintenance window; retry in", e.retry_after) # 503 MAINTENANCE_MODE
|
|
122
|
+
except snipget.APIError as e:
|
|
123
|
+
print("Server error; quote this id to support:", e.request_id)
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
The two 429s mean different things: `RateLimitError` is a per-second throughput throttle and clears in seconds; `QuotaExceededError` means the monthly included calls or prepaid overage allowance are exhausted and will not clear until the monthly reset, a tier upgrade, or an allowance top-up. The client retries the first automatically and never retries the second.
|
|
127
|
+
|
|
128
|
+
## Retries and timeouts
|
|
129
|
+
|
|
130
|
+
```python
|
|
131
|
+
client = Client(
|
|
132
|
+
api_key="...",
|
|
133
|
+
timeout=30.0, # per-request timeout in seconds
|
|
134
|
+
max_retries=2, # retries on top of the initial attempt
|
|
135
|
+
)
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
The client automatically retries network errors, `RATE_LIMITED` 429s (honoring the server's `Retry-After`), and 5xx responses, using exponential backoff with jitter. Snipget utility calls are pure and idempotent, so retrying a POST is safe. It never retries `QUOTA_EXCEEDED` or any other 4xx. Maintenance 503s are retried on the short backoff only; if the window outlasts the retry budget you get a `MaintenanceError` with `retry_after` (typically 300 seconds) so you can schedule your own retry.
|
|
139
|
+
|
|
140
|
+
## The response envelope
|
|
141
|
+
|
|
142
|
+
Every Snipget endpoint, success or error, returns one envelope shape. `call()` returns a `SnipgetResponse`:
|
|
143
|
+
|
|
144
|
+
| Attribute | Type | Meaning |
|
|
145
|
+
| --- | --- | --- |
|
|
146
|
+
| `result` | endpoint-specific | The payload, exactly as the API returned it |
|
|
147
|
+
| `confidence` | `float` | 0.0-1.0 confidence score (1.0 = deterministic match; batch responses always report 1.0 at the top level, with per-item confidences inside `result.items`) |
|
|
148
|
+
| `status` | `str` | `"ok"` on success |
|
|
149
|
+
| `meta.cost_units` | `int` | Billable units consumed by this call |
|
|
150
|
+
| `meta.request_id` | `str` | Server request id; quote it to support |
|
|
151
|
+
| `meta.elapsed_ms` | `int` | Server-side processing time |
|
|
152
|
+
| `meta.version` | `str` | API version |
|
|
153
|
+
| `meta.rate_limit_remaining` / `meta.rate_limit_reset` | `int` | Throughput headroom and bucket reset (unix time) |
|
|
154
|
+
| `meta.quota_remaining` / `meta.quota_reset` | `int` | Monthly included-call headroom and reset (unix time) |
|
|
155
|
+
| `meta.credit_remaining_usd` | `float` | Live prepaid-allowance balance, populated once a call starts burning allowance |
|
|
156
|
+
| `meta.trace` | `list[str]` | Reasoning trace, when the request set `include_trace: true` |
|
|
157
|
+
| `raw` | `dict` | The full unmodified envelope |
|
|
158
|
+
|
|
159
|
+
Meta fields the server didn't send are `None`; unknown future fields stay available via `meta.raw`.
|
|
160
|
+
|
|
161
|
+
## Links
|
|
162
|
+
|
|
163
|
+
- Website: [https://snipget.ai](https://snipget.ai)
|
|
164
|
+
- Interactive API docs: [https://api.snipget.ai/docs](https://api.snipget.ai/docs)
|
|
165
|
+
- OpenAPI spec: [https://api.snipget.ai/openapi.json](https://api.snipget.ai/openapi.json)
|
|
166
|
+
- npm sibling: a JavaScript/TypeScript client (`snipget` on npm) is planned but not yet published
|
|
167
|
+
|
|
168
|
+
## License
|
|
169
|
+
|
|
170
|
+
MIT. Copyright 2026 Snipget Inc.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
snipget/__init__.py,sha256=r-9_yIdBmBg6zV-D3-AiC7vxf_zRCnSC-q0-K0IP8Ro,1153
|
|
2
|
+
snipget/_client.py,sha256=-gzfdPmyTvDuF3jSOOWGDE3ppd45A1Fed9KUIKoooTQ,13074
|
|
3
|
+
snipget/_exceptions.py,sha256=yJwZvuS47AO2geH1IGv-oxAQOgBjDvmWdS3xnO10Ato,5058
|
|
4
|
+
snipget/_response.py,sha256=yFKaxfCgQ6QyaCeJ1C68YsQJvOsXyVFf3fyWqMfg8mM,3400
|
|
5
|
+
snipget/_version.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
|
|
6
|
+
snipget/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
snipget_client-0.1.0.dist-info/METADATA,sha256=AZ-mnn4s5ChIEk1CME80LNXjbgW1dvPPKD5j47h_IwU,8048
|
|
8
|
+
snipget_client-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
9
|
+
snipget_client-0.1.0.dist-info/licenses/LICENSE,sha256=2PzLutoeZb-V1r-HyjZBns49deP9cvKzmdgpj3ZA0cU,1065
|
|
10
|
+
snipget_client-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright 2026 Snipget Inc.
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|