vendorval-sdk 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,44 @@
1
+ """Official Python SDK for the VendorVal API."""
2
+
3
+ from ._async_client import AsyncVendorval
4
+ from ._client import Vendorval
5
+ from ._errors import (
6
+ APIConnectionError,
7
+ APIError,
8
+ APITimeoutError,
9
+ AuthenticationError,
10
+ ConflictError,
11
+ CountryError,
12
+ NotFoundError,
13
+ PermissionError,
14
+ ProviderError,
15
+ RateLimitError,
16
+ ValidationError,
17
+ VendorvalError,
18
+ )
19
+ from ._idempotency import generate_idempotency_key
20
+ from ._pagination import Page
21
+ from ._version import API_VERSION, VERSION
22
+ from ._webhooks import construct_event
23
+
24
+ __all__ = [
25
+ "API_VERSION",
26
+ "APIConnectionError",
27
+ "APIError",
28
+ "APITimeoutError",
29
+ "AsyncVendorval",
30
+ "AuthenticationError",
31
+ "ConflictError",
32
+ "CountryError",
33
+ "NotFoundError",
34
+ "Page",
35
+ "PermissionError",
36
+ "ProviderError",
37
+ "RateLimitError",
38
+ "VERSION",
39
+ "ValidationError",
40
+ "Vendorval",
41
+ "VendorvalError",
42
+ "construct_event",
43
+ "generate_idempotency_key",
44
+ ]
@@ -0,0 +1,84 @@
1
+ """Asynchronous client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from types import TracebackType
6
+
7
+ import httpx
8
+
9
+ from ._request import resolve_config
10
+ from ._version import API_VERSION, VERSION
11
+ from ._webhooks import construct_event
12
+ from .resources._entities import AsyncEntitiesResource
13
+ from .resources._meta import AsyncMetaResource
14
+ from .resources._monitors import AsyncMonitorsResource
15
+ from .resources._simple import AsyncJobsResource, AsyncProvidersResource, AsyncUsageResource
16
+ from .resources._verifications import AsyncVerificationsResource
17
+
18
+
19
+ class _Webhooks:
20
+ construct_event = staticmethod(construct_event)
21
+
22
+
23
+ class AsyncVendorval:
24
+ """Asynchronous VendorVal client.
25
+
26
+ Use as an async context manager so the underlying httpx client is closed:
27
+
28
+ async with AsyncVendorval() as client:
29
+ await client.entities.lookup(identifiers={"uei": "ABCD12345678"})
30
+ """
31
+
32
+ VERSION = VERSION
33
+ API_VERSION = API_VERSION
34
+
35
+ def __init__(
36
+ self,
37
+ *,
38
+ api_key: str | None = None,
39
+ base_url: str | None = None,
40
+ timeout: float | None = None,
41
+ max_retries: int | None = None,
42
+ validate_api_key: bool = True,
43
+ http_client: httpx.AsyncClient | None = None,
44
+ ) -> None:
45
+ self._cfg = resolve_config(
46
+ api_key=api_key,
47
+ base_url=base_url,
48
+ timeout=timeout,
49
+ max_retries=max_retries,
50
+ validate_api_key=validate_api_key,
51
+ )
52
+ self._owns_http = http_client is None
53
+ self._http = http_client or httpx.AsyncClient(timeout=self._cfg.timeout)
54
+ self.entities = AsyncEntitiesResource(self._cfg, self._http)
55
+ self.verifications = AsyncVerificationsResource(self._cfg, self._http)
56
+ self.monitors = AsyncMonitorsResource(self._cfg, self._http)
57
+ self.providers = AsyncProvidersResource(self._cfg, self._http)
58
+ self.meta = AsyncMetaResource(self._cfg, self._http)
59
+ self.usage = AsyncUsageResource(self._cfg, self._http)
60
+ self.jobs = AsyncJobsResource(self._cfg, self._http)
61
+ self.webhooks = _Webhooks()
62
+
63
+ async def aclose(self) -> None:
64
+ if self._owns_http:
65
+ await self._http.aclose()
66
+
67
+ async def __aenter__(self) -> AsyncVendorval:
68
+ return self
69
+
70
+ async def __aexit__(
71
+ self,
72
+ exc_type: type[BaseException] | None,
73
+ exc: BaseException | None,
74
+ tb: TracebackType | None,
75
+ ) -> None:
76
+ await self.aclose()
77
+
78
+ @property
79
+ def api_key(self) -> str:
80
+ return self._cfg.api_key
81
+
82
+ @property
83
+ def base_url(self) -> str:
84
+ return self._cfg.base_url
@@ -0,0 +1,87 @@
1
+ """Synchronous client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from types import TracebackType
6
+
7
+ import httpx
8
+
9
+ from ._request import resolve_config
10
+ from ._version import API_VERSION, VERSION
11
+ from ._webhooks import construct_event
12
+ from .resources._entities import EntitiesResource
13
+ from .resources._meta import MetaResource
14
+ from .resources._monitors import MonitorsResource
15
+ from .resources._simple import JobsResource, ProvidersResource, UsageResource
16
+ from .resources._verifications import VerificationsResource
17
+
18
+
19
+ class _Webhooks:
20
+ construct_event = staticmethod(construct_event)
21
+
22
+
23
+ class Vendorval:
24
+ """Synchronous VendorVal client.
25
+
26
+ Construct directly or use as a context manager:
27
+
28
+ with Vendorval() as client:
29
+ client.entities.lookup(identifiers={"uei": "ABCD12345678"})
30
+
31
+ The constructor reads `VENDORVAL_API_KEY` and `VENDORVAL_BASE_URL` from
32
+ the environment if not supplied.
33
+ """
34
+
35
+ VERSION = VERSION
36
+ API_VERSION = API_VERSION
37
+
38
+ def __init__(
39
+ self,
40
+ *,
41
+ api_key: str | None = None,
42
+ base_url: str | None = None,
43
+ timeout: float | None = None,
44
+ max_retries: int | None = None,
45
+ validate_api_key: bool = True,
46
+ http_client: httpx.Client | None = None,
47
+ ) -> None:
48
+ self._cfg = resolve_config(
49
+ api_key=api_key,
50
+ base_url=base_url,
51
+ timeout=timeout,
52
+ max_retries=max_retries,
53
+ validate_api_key=validate_api_key,
54
+ )
55
+ self._owns_http = http_client is None
56
+ self._http = http_client or httpx.Client(timeout=self._cfg.timeout)
57
+ self.entities = EntitiesResource(self._cfg, self._http)
58
+ self.verifications = VerificationsResource(self._cfg, self._http)
59
+ self.monitors = MonitorsResource(self._cfg, self._http)
60
+ self.providers = ProvidersResource(self._cfg, self._http)
61
+ self.meta = MetaResource(self._cfg, self._http)
62
+ self.usage = UsageResource(self._cfg, self._http)
63
+ self.jobs = JobsResource(self._cfg, self._http)
64
+ self.webhooks = _Webhooks()
65
+
66
+ def close(self) -> None:
67
+ if self._owns_http:
68
+ self._http.close()
69
+
70
+ def __enter__(self) -> Vendorval:
71
+ return self
72
+
73
+ def __exit__(
74
+ self,
75
+ exc_type: type[BaseException] | None,
76
+ exc: BaseException | None,
77
+ tb: TracebackType | None,
78
+ ) -> None:
79
+ self.close()
80
+
81
+ @property
82
+ def api_key(self) -> str:
83
+ return self._cfg.api_key
84
+
85
+ @property
86
+ def base_url(self) -> str:
87
+ return self._cfg.base_url
@@ -0,0 +1,205 @@
1
+ """Error classes mirroring the VendorVal API error envelope."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from email.utils import parsedate_to_datetime
6
+ from typing import Any
7
+
8
+ import httpx
9
+
10
+
11
+ class VendorvalError(Exception):
12
+ """Base class for all SDK errors."""
13
+
14
+ def __init__(
15
+ self,
16
+ message: str,
17
+ *,
18
+ status: int = 0,
19
+ type: str = "api_error",
20
+ code: str = "api_error",
21
+ request_id: str | None = None,
22
+ param: str | None = None,
23
+ details: Any = None,
24
+ headers: httpx.Headers | None = None,
25
+ ) -> None:
26
+ super().__init__(message)
27
+ self.message = message
28
+ self.status = status
29
+ self.type = type
30
+ self.code = code
31
+ self.request_id = request_id
32
+ self.param = param
33
+ self.details = details
34
+ self.headers = headers
35
+
36
+
37
+ class APIError(VendorvalError):
38
+ pass
39
+
40
+
41
+ class AuthenticationError(VendorvalError):
42
+ pass
43
+
44
+
45
+ class PermissionError(VendorvalError): # noqa: A001 - shadowing builtin is intentional
46
+ pass
47
+
48
+
49
+ class ValidationError(VendorvalError):
50
+ pass
51
+
52
+
53
+ class CountryError(ValidationError):
54
+ """422 raised when a request can't be routed to a country/provider.
55
+
56
+ Subclass of :class:`ValidationError` so existing 4xx catch-all handlers
57
+ continue to match. Use ``err.code`` to switch on the specific failure:
58
+
59
+ - ``country_required`` — no explicit country and nothing inferable
60
+ - ``country_not_supported`` — resolved country isn't in SUPPORTED_COUNTRIES
61
+ - ``identifier_not_supported_for_country`` — e.g. ``tin`` with ``country='DE'``
62
+ - ``check_not_supported_for_country`` — e.g. ``sam_registration`` for an EU country
63
+ - ``country_mismatch`` — explicit country contradicts identifier inference
64
+
65
+ The ``details`` attribute carries a :data:`CountryErrorDetails`-shaped dict
66
+ with ``country_resolved``, ``identifiers_seen``, ``recommended_action``,
67
+ ``supported_countries``, and (for ``country_mismatch``) ``candidates``.
68
+ """
69
+
70
+
71
+ _COUNTRY_ERROR_CODES = frozenset(
72
+ {
73
+ "country_required",
74
+ "country_not_supported",
75
+ "identifier_not_supported_for_country",
76
+ "check_not_supported_for_country",
77
+ "country_mismatch",
78
+ }
79
+ )
80
+
81
+
82
+ class NotFoundError(VendorvalError):
83
+ pass
84
+
85
+
86
+ class ConflictError(VendorvalError):
87
+ def __init__(self, *args: Any, candidates: list[Any] | None = None, **kwargs: Any) -> None:
88
+ super().__init__(*args, **kwargs)
89
+ self.candidates = candidates
90
+
91
+
92
+ class RateLimitError(VendorvalError):
93
+ def __init__(self, *args: Any, retry_after: float | None = None, **kwargs: Any) -> None:
94
+ super().__init__(*args, **kwargs)
95
+ self.retry_after = retry_after
96
+
97
+
98
+ class ProviderError(VendorvalError):
99
+ pass
100
+
101
+
102
+ class APIConnectionError(VendorvalError):
103
+ def __init__(self, message: str, *, request_id: str | None = None) -> None:
104
+ super().__init__(
105
+ message,
106
+ status=0,
107
+ type="connection_error",
108
+ code="connection_error",
109
+ request_id=request_id,
110
+ )
111
+
112
+
113
+ class APITimeoutError(APIConnectionError):
114
+ def __init__(self, timeout: float, *, request_id: str | None = None) -> None:
115
+ super().__init__(f"Request timed out after {timeout}s", request_id=request_id)
116
+
117
+
118
+ _STATUS_TO_CLS: dict[int, type[VendorvalError]] = {
119
+ 400: ValidationError,
120
+ 401: AuthenticationError,
121
+ 403: PermissionError,
122
+ 404: NotFoundError,
123
+ # 422 is an invalid-request status the API uses for semantic-validation
124
+ # failures (Phase J country routing emits 422 + CountryError, but other
125
+ # semantic violations also land here). Mapping to ValidationError keeps
126
+ # catch-all 4xx handlers working consistently.
127
+ 422: ValidationError,
128
+ 429: RateLimitError,
129
+ 502: ProviderError,
130
+ }
131
+
132
+
133
+ def parse_retry_after(value: str | None) -> float | None:
134
+ if not value:
135
+ return None
136
+ try:
137
+ return float(value)
138
+ except ValueError:
139
+ pass
140
+ try:
141
+ dt = parsedate_to_datetime(value)
142
+ from datetime import datetime, timezone
143
+
144
+ return max(0.0, (dt - datetime.now(timezone.utc)).total_seconds())
145
+ except (TypeError, ValueError):
146
+ return None
147
+
148
+
149
+ def error_from_response(
150
+ *,
151
+ status: int,
152
+ payload: Any,
153
+ headers: httpx.Headers,
154
+ request_id: str | None,
155
+ ) -> VendorvalError:
156
+ envelope = None
157
+ if isinstance(payload, dict) and isinstance(payload.get("error"), dict):
158
+ envelope = payload["error"]
159
+
160
+ msg = (envelope or {}).get("message") or f"VendorVal API error (status {status})"
161
+ type_ = (envelope or {}).get("type") or "api_error"
162
+ code = (envelope or {}).get("code") or f"http_{status}"
163
+ param = (envelope or {}).get("param")
164
+ details = (envelope or {}).get("details")
165
+
166
+ common: dict[str, Any] = dict(
167
+ message=msg,
168
+ status=status,
169
+ type=type_,
170
+ code=code,
171
+ request_id=request_id,
172
+ param=param,
173
+ details=details,
174
+ headers=headers,
175
+ )
176
+
177
+ if status == 409:
178
+ candidates = None
179
+ if envelope:
180
+ if isinstance(envelope.get("candidates"), list):
181
+ candidates = envelope["candidates"]
182
+ elif isinstance(envelope.get("details"), dict):
183
+ inner = envelope["details"].get("candidates")
184
+ if isinstance(inner, list):
185
+ candidates = inner
186
+ return ConflictError(**common, candidates=candidates)
187
+
188
+ if status == 429:
189
+ return RateLimitError(
190
+ **common,
191
+ retry_after=parse_retry_after(headers.get("retry-after")),
192
+ )
193
+
194
+ # Phase J: 422 envelopes with a country-routing code surface as CountryError
195
+ # (a ValidationError subclass) so consumers can switch on `err.code` and
196
+ # inspect the structured `err.details` payload. The `isinstance(code, str)`
197
+ # guard handles malformed payloads where `code` is a non-string (e.g. a list
198
+ # or dict) — set membership on a frozenset[str] would otherwise TypeError
199
+ # on unhashable values, breaking error normalization for the rest of the
200
+ # response.
201
+ if status == 422 and isinstance(code, str) and code in _COUNTRY_ERROR_CODES:
202
+ return CountryError(**common)
203
+
204
+ cls = _STATUS_TO_CLS.get(status, APIError)
205
+ return cls(**common)
@@ -0,0 +1,10 @@
1
+ """Idempotency key helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import uuid
6
+
7
+
8
+ def generate_idempotency_key() -> str:
9
+ """Return a UUID4 string suitable for `options.idempotency_key`."""
10
+ return str(uuid.uuid4())
@@ -0,0 +1,59 @@
1
+ """Lightweight response wrappers that expose `request_id`.
2
+
3
+ Kept as `__getattr__`-backed dict facades so we don't tie the SDK to a strict
4
+ schema while the API is still stabilizing.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Any
10
+
11
+
12
+ class Response:
13
+ """Dict-backed object that exposes the API response plus `_request_id`."""
14
+
15
+ __slots__ = ("_data", "_request_id", "_status")
16
+
17
+ def __init__(self, data: Any, request_id: str | None, status: int) -> None:
18
+ self._data = data if isinstance(data, dict) else {}
19
+ self._request_id = request_id
20
+ self._status = status
21
+
22
+ def __getattr__(self, name: str) -> Any:
23
+ try:
24
+ return self._data[name]
25
+ except KeyError as err:
26
+ raise AttributeError(name) from err
27
+
28
+ def __getitem__(self, key: str) -> Any:
29
+ return self._data[key]
30
+
31
+ def __contains__(self, key: str) -> bool:
32
+ return key in self._data
33
+
34
+ def get(self, key: str, default: Any = None) -> Any:
35
+ return self._data.get(key, default)
36
+
37
+ def to_dict(self) -> dict[str, Any]:
38
+ return dict(self._data)
39
+
40
+ @property
41
+ def request_id(self) -> str | None:
42
+ return self._request_id
43
+
44
+ @property
45
+ def status(self) -> int:
46
+ return self._status
47
+
48
+ def __repr__(self) -> str:
49
+ return f"<Response request_id={self._request_id!r} status={self._status}>"
50
+
51
+
52
+ class VerificationBundleResponse(Response):
53
+ @property
54
+ def entity(self) -> Response:
55
+ return Response(self._data.get("entity"), self._request_id, self._status)
56
+
57
+ @property
58
+ def verification(self) -> Response:
59
+ return Response(self._data.get("verification"), self._request_id, self._status)
@@ -0,0 +1,34 @@
1
+ """Pagination helpers.
2
+
3
+ Today list endpoints return arrays. Wrapping in `Page` keeps the
4
+ `for item in page` and `async for item in page` shapes stable for when
5
+ the API ships cursor pagination.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import AsyncIterator, Iterator
11
+ from typing import Generic, TypeVar
12
+
13
+ T = TypeVar("T")
14
+
15
+
16
+ class Page(Generic[T]):
17
+ def __init__(self, items: list[T]) -> None:
18
+ self._items = list(items)
19
+
20
+ def __iter__(self) -> Iterator[T]:
21
+ return iter(self._items)
22
+
23
+ async def __aiter__(self) -> AsyncIterator[T]:
24
+ for item in self._items:
25
+ yield item
26
+
27
+ def __len__(self) -> int:
28
+ return len(self._items)
29
+
30
+ def __getitem__(self, index: int) -> T:
31
+ return self._items[index]
32
+
33
+ def all(self) -> list[T]:
34
+ return list(self._items)