tarifftrust 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.
- tarifftrust/__init__.py +39 -0
- tarifftrust/_client.py +326 -0
- tarifftrust/_errors.py +105 -0
- tarifftrust/_model.py +89 -0
- tarifftrust/_operations.py +870 -0
- tarifftrust/_version.py +3 -0
- tarifftrust/py.typed +0 -0
- tarifftrust/types.py +1651 -0
- tarifftrust-0.1.0.dist-info/METADATA +223 -0
- tarifftrust-0.1.0.dist-info/RECORD +12 -0
- tarifftrust-0.1.0.dist-info/WHEEL +4 -0
- tarifftrust-0.1.0.dist-info/licenses/LICENSE +21 -0
tarifftrust/__init__.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Official Python client for the TariffTrust API: every US import duty and fee for an HTS code,
|
|
2
|
+
an origin and a value, each with its official source. https://tarifftrust.com/api/"""
|
|
3
|
+
|
|
4
|
+
from ._client import AsyncTariffTrust, TariffTrust
|
|
5
|
+
from ._errors import (
|
|
6
|
+
AccessRequiredError,
|
|
7
|
+
APIConnectionError,
|
|
8
|
+
APIError,
|
|
9
|
+
APITimeoutError,
|
|
10
|
+
AuthenticationError,
|
|
11
|
+
ConflictError,
|
|
12
|
+
InvalidInputError,
|
|
13
|
+
NotFoundError,
|
|
14
|
+
PayloadTooLargeError,
|
|
15
|
+
PermissionDeniedError,
|
|
16
|
+
RateLimitError,
|
|
17
|
+
ServiceUnavailableError,
|
|
18
|
+
TariffTrustError,
|
|
19
|
+
)
|
|
20
|
+
from ._version import __version__
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"APIConnectionError",
|
|
24
|
+
"APIError",
|
|
25
|
+
"APITimeoutError",
|
|
26
|
+
"AccessRequiredError",
|
|
27
|
+
"AsyncTariffTrust",
|
|
28
|
+
"AuthenticationError",
|
|
29
|
+
"ConflictError",
|
|
30
|
+
"InvalidInputError",
|
|
31
|
+
"NotFoundError",
|
|
32
|
+
"PayloadTooLargeError",
|
|
33
|
+
"PermissionDeniedError",
|
|
34
|
+
"RateLimitError",
|
|
35
|
+
"ServiceUnavailableError",
|
|
36
|
+
"TariffTrust",
|
|
37
|
+
"TariffTrustError",
|
|
38
|
+
"__version__",
|
|
39
|
+
]
|
tarifftrust/_client.py
ADDED
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
"""The clients: the key, the retries, the idempotency key and the errors around each operation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import platform
|
|
9
|
+
import random
|
|
10
|
+
import time
|
|
11
|
+
import uuid
|
|
12
|
+
from collections.abc import Mapping
|
|
13
|
+
from datetime import date
|
|
14
|
+
from decimal import Decimal
|
|
15
|
+
from types import TracebackType
|
|
16
|
+
from typing import Any, TypeVar
|
|
17
|
+
|
|
18
|
+
import httpx
|
|
19
|
+
|
|
20
|
+
from ._errors import (
|
|
21
|
+
BY_STATUS,
|
|
22
|
+
APIConnectionError,
|
|
23
|
+
APIError,
|
|
24
|
+
APITimeoutError,
|
|
25
|
+
AuthenticationError,
|
|
26
|
+
TariffTrustError,
|
|
27
|
+
)
|
|
28
|
+
from ._model import Model
|
|
29
|
+
from ._operations import AsyncOperations, Operations
|
|
30
|
+
from ._version import __version__
|
|
31
|
+
|
|
32
|
+
DEFAULT_BASE_URL = "https://api.tarifftrust.com"
|
|
33
|
+
DEFAULT_TIMEOUT = 60.0
|
|
34
|
+
DEFAULT_MAX_RETRIES = 2
|
|
35
|
+
DEFAULT_MAX_RETRY_WAIT = 60.0
|
|
36
|
+
# What a gateway answers when the service behind it is away, without the API's error.
|
|
37
|
+
GATEWAY_STATUSES = {429, 502, 503, 504}
|
|
38
|
+
USER_AGENT = f"tarifftrust-python/{__version__} python/{platform.python_version()}"
|
|
39
|
+
_M = TypeVar("_M", bound=Model)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _encode(value: Any) -> str:
|
|
43
|
+
if isinstance(value, Decimal):
|
|
44
|
+
return str(value)
|
|
45
|
+
if isinstance(value, date):
|
|
46
|
+
return value.isoformat()
|
|
47
|
+
raise TypeError(f"{type(value).__name__} cannot be sent to the API")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _sent(fields: Mapping[str, Any] | None) -> dict[str, Any]:
|
|
51
|
+
"""The arguments given: one left at None is not sent, and the API takes its default."""
|
|
52
|
+
return {name: value for name, value in (fields or {}).items() if value is not None}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _retry_after(response: httpx.Response) -> int | None:
|
|
56
|
+
value = response.headers.get("Retry-After", "")
|
|
57
|
+
return int(value) if value.isdigit() else None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _error(response: httpx.Response) -> APIError:
|
|
61
|
+
try:
|
|
62
|
+
body = response.json()
|
|
63
|
+
except ValueError:
|
|
64
|
+
body = None
|
|
65
|
+
error = body.get("error") if isinstance(body, dict) else None
|
|
66
|
+
# The admin-style error of a proxy may be a plain string: only an object is the API's.
|
|
67
|
+
error = error if isinstance(error, dict) else {}
|
|
68
|
+
kind = BY_STATUS.get(response.status_code, APIError)
|
|
69
|
+
return kind(
|
|
70
|
+
error.get("message") or f"HTTP {response.status_code}",
|
|
71
|
+
status=response.status_code,
|
|
72
|
+
code=error.get("code"),
|
|
73
|
+
fields=error.get("fields"),
|
|
74
|
+
request_id=(body.get("request_id") if isinstance(body, dict) else None)
|
|
75
|
+
or response.headers.get("X-Request-ID"),
|
|
76
|
+
retry_after=_retry_after(response),
|
|
77
|
+
body=body,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _backoff(attempt: int) -> float:
|
|
82
|
+
return min(0.5 * float(2**attempt), 8.0) * random.uniform(0.5, 1.0)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _wait_before_retry(error: APIError, attempt: int, longest: float) -> float | None:
|
|
86
|
+
"""How long to wait before trying again, None when trying again cannot help. The API says so
|
|
87
|
+
by its Retry-After: a refusal without one stays refused, and one too long is the caller's."""
|
|
88
|
+
if error.retry_after is not None:
|
|
89
|
+
return float(error.retry_after) if error.retry_after <= longest else None
|
|
90
|
+
if error.code is None and error.status in GATEWAY_STATUSES:
|
|
91
|
+
return _backoff(attempt)
|
|
92
|
+
return None
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _answer(response: httpx.Response, model: type[_M]) -> _M:
|
|
96
|
+
try:
|
|
97
|
+
return model(response.json())
|
|
98
|
+
except ValueError as exc:
|
|
99
|
+
status = response.status_code
|
|
100
|
+
raise TariffTrustError(f"The API's answer is not JSON (HTTP {status})") from exc
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _unreachable(exc: httpx.RequestError) -> APIConnectionError:
|
|
104
|
+
if isinstance(exc, httpx.TimeoutException):
|
|
105
|
+
return APITimeoutError(f"The API did not answer in time: {exc}")
|
|
106
|
+
return APIConnectionError(f"The API could not be reached: {exc}")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class _Settings:
|
|
110
|
+
def __init__(
|
|
111
|
+
self,
|
|
112
|
+
api_key: str | None,
|
|
113
|
+
base_url: str | None,
|
|
114
|
+
timeout: float,
|
|
115
|
+
max_retries: int,
|
|
116
|
+
max_retry_wait: float,
|
|
117
|
+
) -> None:
|
|
118
|
+
# A stray newline would be refused as a header, with the key in the message.
|
|
119
|
+
self.api_key = (api_key or os.environ.get("TARIFFTRUST_API_KEY") or "").strip() or None
|
|
120
|
+
self.timeout = timeout
|
|
121
|
+
found = base_url or os.environ.get("TARIFFTRUST_BASE_URL") or DEFAULT_BASE_URL
|
|
122
|
+
self.base_url = found.rstrip("/")
|
|
123
|
+
self.max_retries = max_retries
|
|
124
|
+
self.max_retry_wait = max_retry_wait
|
|
125
|
+
|
|
126
|
+
def request(
|
|
127
|
+
self,
|
|
128
|
+
method: str,
|
|
129
|
+
path: str,
|
|
130
|
+
*,
|
|
131
|
+
query: Mapping[str, Any] | None,
|
|
132
|
+
body: Mapping[str, Any] | None,
|
|
133
|
+
auth: bool,
|
|
134
|
+
idempotent: bool,
|
|
135
|
+
idempotency_key: str | None,
|
|
136
|
+
) -> dict[str, Any]:
|
|
137
|
+
"""What each attempt of a call sends: the idempotency key is the call's, so that a retry
|
|
138
|
+
counts once."""
|
|
139
|
+
headers = {"User-Agent": USER_AGENT, "Accept": "application/json"}
|
|
140
|
+
if auth:
|
|
141
|
+
if not self.api_key:
|
|
142
|
+
raise AuthenticationError(
|
|
143
|
+
"No API key: pass api_key or set TARIFFTRUST_API_KEY", status=401
|
|
144
|
+
)
|
|
145
|
+
headers["Authorization"] = f"Bearer {self.api_key}"
|
|
146
|
+
if idempotent:
|
|
147
|
+
if idempotency_key and not idempotency_key.isascii():
|
|
148
|
+
raise ValueError("An idempotency key is a header: use only ASCII characters")
|
|
149
|
+
headers["Idempotency-Key"] = idempotency_key or str(uuid.uuid4())
|
|
150
|
+
request: dict[str, Any] = {
|
|
151
|
+
"method": method,
|
|
152
|
+
"url": self.base_url + path,
|
|
153
|
+
"headers": headers,
|
|
154
|
+
"params": _sent(query),
|
|
155
|
+
"timeout": self.timeout,
|
|
156
|
+
}
|
|
157
|
+
if body is not None:
|
|
158
|
+
headers["Content-Type"] = "application/json"
|
|
159
|
+
request["content"] = json.dumps(_sent(body), default=_encode)
|
|
160
|
+
return request
|
|
161
|
+
|
|
162
|
+
def wait_after_failure(self, failure: httpx.RequestError, attempt: int) -> float:
|
|
163
|
+
"""The seconds before trying again after a failure to reach the API, or what it raises."""
|
|
164
|
+
if attempt >= self.max_retries:
|
|
165
|
+
raise _unreachable(failure) from failure
|
|
166
|
+
return _backoff(attempt)
|
|
167
|
+
|
|
168
|
+
def wait_after_refusal(self, response: httpx.Response, attempt: int) -> float:
|
|
169
|
+
"""The seconds before trying again after a refusal, or the error the call ends with."""
|
|
170
|
+
error = _error(response)
|
|
171
|
+
wait = _wait_before_retry(error, attempt, self.max_retry_wait)
|
|
172
|
+
if wait is None or attempt >= self.max_retries:
|
|
173
|
+
raise error
|
|
174
|
+
return wait
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
class TariffTrust(Operations):
|
|
178
|
+
"""The TariffTrust API. Each operation of the API is a method under its operationId.
|
|
179
|
+
|
|
180
|
+
Args:
|
|
181
|
+
api_key: The key, or TARIFFTRUST_API_KEY. Keep it on your server. Only ``get_status``
|
|
182
|
+
works without one.
|
|
183
|
+
base_url: The API's address, or TARIFFTRUST_BASE_URL; the production's otherwise.
|
|
184
|
+
timeout: The seconds to wait at each step of an attempt: connecting, sending, each read.
|
|
185
|
+
It applies to your own ``http_client`` too.
|
|
186
|
+
max_retries: How many times a call is tried again: after a connection failure, a timeout
|
|
187
|
+
or a gateway error, or when the API gives a Retry-After. An idempotency key makes these
|
|
188
|
+
retries count once.
|
|
189
|
+
max_retry_wait: The longest Retry-After waited for, in seconds; a longer one is raised.
|
|
190
|
+
http_client: Your own ``httpx.Client`` (a proxy, your certificates): yours to close.
|
|
191
|
+
"""
|
|
192
|
+
|
|
193
|
+
def __init__(
|
|
194
|
+
self,
|
|
195
|
+
api_key: str | None = None,
|
|
196
|
+
*,
|
|
197
|
+
base_url: str | None = None,
|
|
198
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
199
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
200
|
+
max_retry_wait: float = DEFAULT_MAX_RETRY_WAIT,
|
|
201
|
+
http_client: httpx.Client | None = None,
|
|
202
|
+
) -> None:
|
|
203
|
+
self._settings = _Settings(api_key, base_url, timeout, max_retries, max_retry_wait)
|
|
204
|
+
self._owns_http = http_client is None
|
|
205
|
+
self._http = http_client or httpx.Client()
|
|
206
|
+
|
|
207
|
+
def _request(
|
|
208
|
+
self,
|
|
209
|
+
method: str,
|
|
210
|
+
path: str,
|
|
211
|
+
*,
|
|
212
|
+
model: type[_M],
|
|
213
|
+
query: Mapping[str, Any] | None = None,
|
|
214
|
+
body: Mapping[str, Any] | None = None,
|
|
215
|
+
auth: bool = True,
|
|
216
|
+
idempotent: bool = False,
|
|
217
|
+
idempotency_key: str | None = None,
|
|
218
|
+
) -> _M:
|
|
219
|
+
settings = self._settings
|
|
220
|
+
request = settings.request(
|
|
221
|
+
method,
|
|
222
|
+
path,
|
|
223
|
+
query=query,
|
|
224
|
+
body=body,
|
|
225
|
+
auth=auth,
|
|
226
|
+
idempotent=idempotent,
|
|
227
|
+
idempotency_key=idempotency_key,
|
|
228
|
+
)
|
|
229
|
+
attempt = 0
|
|
230
|
+
while True:
|
|
231
|
+
try:
|
|
232
|
+
response = self._http.request(**request)
|
|
233
|
+
except httpx.RequestError as exc:
|
|
234
|
+
wait = settings.wait_after_failure(exc, attempt)
|
|
235
|
+
else:
|
|
236
|
+
if response.is_success:
|
|
237
|
+
return _answer(response, model)
|
|
238
|
+
wait = settings.wait_after_refusal(response, attempt)
|
|
239
|
+
time.sleep(wait)
|
|
240
|
+
attempt += 1
|
|
241
|
+
|
|
242
|
+
def close(self) -> None:
|
|
243
|
+
"""Close the connections; your own ``http_client`` stays open."""
|
|
244
|
+
if self._owns_http:
|
|
245
|
+
self._http.close()
|
|
246
|
+
|
|
247
|
+
def __enter__(self) -> TariffTrust:
|
|
248
|
+
return self
|
|
249
|
+
|
|
250
|
+
def __exit__(
|
|
251
|
+
self,
|
|
252
|
+
kind: type[BaseException] | None,
|
|
253
|
+
value: BaseException | None,
|
|
254
|
+
traceback: TracebackType | None,
|
|
255
|
+
) -> None:
|
|
256
|
+
self.close()
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
class AsyncTariffTrust(AsyncOperations):
|
|
260
|
+
"""The TariffTrust API for ``asyncio``: the methods and arguments of ``TariffTrust``, awaited.
|
|
261
|
+
``http_client`` is your own ``httpx.AsyncClient``: yours to close."""
|
|
262
|
+
|
|
263
|
+
def __init__(
|
|
264
|
+
self,
|
|
265
|
+
api_key: str | None = None,
|
|
266
|
+
*,
|
|
267
|
+
base_url: str | None = None,
|
|
268
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
269
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
270
|
+
max_retry_wait: float = DEFAULT_MAX_RETRY_WAIT,
|
|
271
|
+
http_client: httpx.AsyncClient | None = None,
|
|
272
|
+
) -> None:
|
|
273
|
+
self._settings = _Settings(api_key, base_url, timeout, max_retries, max_retry_wait)
|
|
274
|
+
self._owns_http = http_client is None
|
|
275
|
+
self._http = http_client or httpx.AsyncClient()
|
|
276
|
+
|
|
277
|
+
async def _request(
|
|
278
|
+
self,
|
|
279
|
+
method: str,
|
|
280
|
+
path: str,
|
|
281
|
+
*,
|
|
282
|
+
model: type[_M],
|
|
283
|
+
query: Mapping[str, Any] | None = None,
|
|
284
|
+
body: Mapping[str, Any] | None = None,
|
|
285
|
+
auth: bool = True,
|
|
286
|
+
idempotent: bool = False,
|
|
287
|
+
idempotency_key: str | None = None,
|
|
288
|
+
) -> _M:
|
|
289
|
+
settings = self._settings
|
|
290
|
+
request = settings.request(
|
|
291
|
+
method,
|
|
292
|
+
path,
|
|
293
|
+
query=query,
|
|
294
|
+
body=body,
|
|
295
|
+
auth=auth,
|
|
296
|
+
idempotent=idempotent,
|
|
297
|
+
idempotency_key=idempotency_key,
|
|
298
|
+
)
|
|
299
|
+
attempt = 0
|
|
300
|
+
while True:
|
|
301
|
+
try:
|
|
302
|
+
response = await self._http.request(**request)
|
|
303
|
+
except httpx.RequestError as exc:
|
|
304
|
+
wait = settings.wait_after_failure(exc, attempt)
|
|
305
|
+
else:
|
|
306
|
+
if response.is_success:
|
|
307
|
+
return _answer(response, model)
|
|
308
|
+
wait = settings.wait_after_refusal(response, attempt)
|
|
309
|
+
await asyncio.sleep(wait)
|
|
310
|
+
attempt += 1
|
|
311
|
+
|
|
312
|
+
async def close(self) -> None:
|
|
313
|
+
"""Close the connections; your own ``http_client`` stays open."""
|
|
314
|
+
if self._owns_http:
|
|
315
|
+
await self._http.aclose()
|
|
316
|
+
|
|
317
|
+
async def __aenter__(self) -> AsyncTariffTrust:
|
|
318
|
+
return self
|
|
319
|
+
|
|
320
|
+
async def __aexit__(
|
|
321
|
+
self,
|
|
322
|
+
kind: type[BaseException] | None,
|
|
323
|
+
value: BaseException | None,
|
|
324
|
+
traceback: TracebackType | None,
|
|
325
|
+
) -> None:
|
|
326
|
+
await self.close()
|
tarifftrust/_errors.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""What a call may raise: the API's refusals by their status, and the failures to reach it."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class TariffTrustError(Exception):
|
|
9
|
+
"""What a call raises when the API refuses it or cannot be reached. An argument that cannot
|
|
10
|
+
be sent at all is a ``TypeError`` or a ``ValueError``, before any request."""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class APIConnectionError(TariffTrustError):
|
|
14
|
+
"""The API could not be reached, after the retries."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class APITimeoutError(APIConnectionError):
|
|
18
|
+
"""The API did not answer in time, after the retries."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class APIError(TariffTrustError):
|
|
22
|
+
"""The API refused the request. ``code`` is for a program, ``message`` for a person,
|
|
23
|
+
``fields`` names what invalid_input refused, ``request_id`` is what to quote to support, and
|
|
24
|
+
``retry_after`` the seconds to wait when trying again helps, None otherwise."""
|
|
25
|
+
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
message: str,
|
|
29
|
+
*,
|
|
30
|
+
status: int,
|
|
31
|
+
code: str | None = None,
|
|
32
|
+
fields: list[str] | None = None,
|
|
33
|
+
request_id: str | None = None,
|
|
34
|
+
retry_after: int | None = None,
|
|
35
|
+
body: Any = None,
|
|
36
|
+
) -> None:
|
|
37
|
+
super().__init__(message)
|
|
38
|
+
self.message = message
|
|
39
|
+
self.status = status
|
|
40
|
+
self.code = code
|
|
41
|
+
self.fields = fields or []
|
|
42
|
+
self.request_id = request_id
|
|
43
|
+
self.retry_after = retry_after
|
|
44
|
+
self.body = body
|
|
45
|
+
|
|
46
|
+
def __str__(self) -> str:
|
|
47
|
+
details = [f"HTTP {self.status}"]
|
|
48
|
+
if self.fields:
|
|
49
|
+
details.append("fields: " + ", ".join(self.fields))
|
|
50
|
+
if self.request_id:
|
|
51
|
+
details.append(f"request {self.request_id}")
|
|
52
|
+
prefix = f"{self.code}: " if self.code else ""
|
|
53
|
+
return f"{prefix}{self.message} ({'; '.join(details)})"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class AuthenticationError(APIError):
|
|
57
|
+
"""401: no valid API key."""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class AccessRequiredError(APIError):
|
|
61
|
+
"""402: no active subscription, or the quota is used up (code ``quota_exceeded``)."""
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class PermissionDeniedError(APIError):
|
|
65
|
+
"""403: the key lacks the permission, or the plan the API."""
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class NotFoundError(APIError):
|
|
69
|
+
"""404: nothing of that name."""
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class ConflictError(APIError):
|
|
73
|
+
"""409: an idempotency key sent again with another request."""
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class PayloadTooLargeError(APIError):
|
|
77
|
+
"""413: the body is larger than the API takes: send fewer products or lines."""
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class InvalidInputError(APIError):
|
|
81
|
+
"""422: ``fields`` names each field refused; 400: the body is not valid JSON."""
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class RateLimitError(APIError):
|
|
85
|
+
"""429: too many requests."""
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class ServiceUnavailableError(APIError):
|
|
89
|
+
"""502, 503 or 504: unavailable for now."""
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
BY_STATUS: dict[int, type[APIError]] = {
|
|
93
|
+
400: InvalidInputError,
|
|
94
|
+
401: AuthenticationError,
|
|
95
|
+
402: AccessRequiredError,
|
|
96
|
+
403: PermissionDeniedError,
|
|
97
|
+
404: NotFoundError,
|
|
98
|
+
409: ConflictError,
|
|
99
|
+
413: PayloadTooLargeError,
|
|
100
|
+
422: InvalidInputError,
|
|
101
|
+
429: RateLimitError,
|
|
102
|
+
502: ServiceUnavailableError,
|
|
103
|
+
503: ServiceUnavailableError,
|
|
104
|
+
504: ServiceUnavailableError,
|
|
105
|
+
}
|
tarifftrust/_model.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""An object of an answer: its fields as attributes, built from the JSON when first read."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from typing import Any, ClassVar
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Model:
|
|
10
|
+
"""An object of the API. A field the answer leaves out reads as its default in the contract,
|
|
11
|
+
or None; a field newer than this package reads as an attribute too; ``to_dict()`` gives the
|
|
12
|
+
object as the API sent it."""
|
|
13
|
+
|
|
14
|
+
_registry: ClassVar[dict[str, type[Model]]] = {}
|
|
15
|
+
_fields: ClassVar[frozenset[str]] = frozenset()
|
|
16
|
+
_required: ClassVar[frozenset[str]] = frozenset()
|
|
17
|
+
_nested: ClassVar[dict[str, Any]] = {}
|
|
18
|
+
_defaults: ClassVar[dict[str, Any]] = {}
|
|
19
|
+
|
|
20
|
+
def __init_subclass__(cls, **kwargs: Any) -> None:
|
|
21
|
+
super().__init_subclass__(**kwargs)
|
|
22
|
+
Model._registry[cls.__name__] = cls
|
|
23
|
+
|
|
24
|
+
def __init__(self, data: Mapping[str, Any]) -> None:
|
|
25
|
+
self._data = data
|
|
26
|
+
self._read: dict[str, Any] = {}
|
|
27
|
+
|
|
28
|
+
def __getattr__(self, name: str) -> Any:
|
|
29
|
+
# Only for what is not found otherwise; a private name is never a field.
|
|
30
|
+
if name.startswith("_"):
|
|
31
|
+
raise AttributeError(name)
|
|
32
|
+
field = name.removesuffix("_")
|
|
33
|
+
if field in self._read:
|
|
34
|
+
return self._read[field]
|
|
35
|
+
if field in self._data:
|
|
36
|
+
value = _build(self._nested.get(field), self._data[field])
|
|
37
|
+
elif field in self._fields:
|
|
38
|
+
value = _copy(self._defaults.get(field))
|
|
39
|
+
else:
|
|
40
|
+
raise AttributeError(f"{type(self).__name__} has no field {name!r}")
|
|
41
|
+
self._read[field] = value
|
|
42
|
+
return value
|
|
43
|
+
|
|
44
|
+
def __dir__(self) -> list[str]:
|
|
45
|
+
return sorted({*object.__dir__(self), *self._fields, *self._data})
|
|
46
|
+
|
|
47
|
+
def __eq__(self, other: object) -> bool:
|
|
48
|
+
return (
|
|
49
|
+
isinstance(other, type(self))
|
|
50
|
+
and type(other) is type(self)
|
|
51
|
+
and other._data == self._data
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
__hash__ = None # type: ignore[assignment]
|
|
55
|
+
|
|
56
|
+
def __repr__(self) -> str:
|
|
57
|
+
scalars = [
|
|
58
|
+
f"{field}={value!r}"
|
|
59
|
+
for field, value in self._data.items()
|
|
60
|
+
if not isinstance(value, (dict, list))
|
|
61
|
+
]
|
|
62
|
+
return f"{type(self).__name__}({', '.join(scalars[:6])}{', ...' if scalars[6:] else ''})"
|
|
63
|
+
|
|
64
|
+
def to_dict(self) -> dict[str, Any]:
|
|
65
|
+
"""The object as the API sent it, as plain JSON values."""
|
|
66
|
+
return dict(self._data)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _copy(value: Any) -> Any:
|
|
70
|
+
return list(value) if isinstance(value, list) else value
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _build(spec: Any, value: Any) -> Any:
|
|
74
|
+
"""A JSON value as the objects ``spec`` names: a name, [of a list], {"*": of a map}, (union)."""
|
|
75
|
+
if spec is None or value is None:
|
|
76
|
+
return value
|
|
77
|
+
if isinstance(spec, str):
|
|
78
|
+
return Model._registry[spec](value) if isinstance(value, Mapping) else value
|
|
79
|
+
if isinstance(spec, list):
|
|
80
|
+
return [_build(spec[0], item) for item in value] if isinstance(value, list) else value
|
|
81
|
+
if isinstance(spec, dict):
|
|
82
|
+
if not isinstance(value, Mapping):
|
|
83
|
+
return value
|
|
84
|
+
return {key: _build(spec["*"], item) for key, item in value.items()}
|
|
85
|
+
if isinstance(value, Mapping):
|
|
86
|
+
for name in spec:
|
|
87
|
+
if Model._registry[name]._required <= value.keys():
|
|
88
|
+
return Model._registry[name](value)
|
|
89
|
+
return value
|