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 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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.