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.
@@ -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