salestax-python 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.
- salestax/__init__.py +61 -0
- salestax/_config.py +53 -0
- salestax/_transport/__init__.py +18 -0
- salestax/_transport/http_client.py +287 -0
- salestax/_transport/httpx_transport.py +54 -0
- salestax/_transport/retry.py +49 -0
- salestax/_transport/types.py +91 -0
- salestax/_transport/urllib_transport.py +55 -0
- salestax/_transport/user_agent.py +15 -0
- salestax/_version.py +3 -0
- salestax/client.py +215 -0
- salestax/errors.py +202 -0
- salestax/models.py +80 -0
- salestax/py.typed +1 -0
- salestax/resources/__init__.py +5 -0
- salestax/resources/jurisdictions.py +27 -0
- salestax/resources/rates.py +25 -0
- salestax/resources/tax.py +146 -0
- salestax/utils.py +22 -0
- salestax_python-0.1.0.dist-info/METADATA +158 -0
- salestax_python-0.1.0.dist-info/RECORD +23 -0
- salestax_python-0.1.0.dist-info/WHEEL +4 -0
- salestax_python-0.1.0.dist-info/licenses/LICENSE +21 -0
salestax/client.py
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
"""Public client facades — sync and async."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from ._config import (
|
|
8
|
+
DEFAULT_BASE_URL,
|
|
9
|
+
DEFAULT_TIMEOUT_MS,
|
|
10
|
+
ClientOptions,
|
|
11
|
+
RetryPolicy,
|
|
12
|
+
)
|
|
13
|
+
from ._transport.http_client import AsyncHttpClient, HttpClient
|
|
14
|
+
from ._transport.types import AsyncTransport, Hooks, SyncTransport
|
|
15
|
+
from .resources.jurisdictions import JurisdictionsResource
|
|
16
|
+
from .resources.rates import RatesResource
|
|
17
|
+
from .resources.tax import TaxResource
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class SalesTaxClient:
|
|
21
|
+
"""Synchronous client for the Sales Tax Calculator API.
|
|
22
|
+
|
|
23
|
+
Example:
|
|
24
|
+
>>> from salestax import SalesTaxClient
|
|
25
|
+
>>> with SalesTaxClient() as client:
|
|
26
|
+
... tax = client.tax.calculate(zip_code="90210", amount=100)
|
|
27
|
+
>>> tax["taxAmount"]
|
|
28
|
+
9.75
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
api_key: API key. Falls back to ``SALESTAX_API_KEY`` when omitted.
|
|
32
|
+
base_url: Override the API base URL.
|
|
33
|
+
timeout_ms: Per-request timeout in milliseconds.
|
|
34
|
+
retry: Custom retry policy. Fields not set keep defaults.
|
|
35
|
+
default_headers: Headers merged into every request.
|
|
36
|
+
transport: Custom transport implementing :class:`SyncTransport`.
|
|
37
|
+
hooks: Lifecycle callbacks (see :class:`Hooks`).
|
|
38
|
+
chunk_batch: If True, ``calculate_batch`` auto-splits inputs >100.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def __init__(
|
|
42
|
+
self,
|
|
43
|
+
api_key: str | None = None,
|
|
44
|
+
*,
|
|
45
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
46
|
+
timeout_ms: int = DEFAULT_TIMEOUT_MS,
|
|
47
|
+
retry: RetryPolicy | None = None,
|
|
48
|
+
default_headers: dict[str, str] | None = None,
|
|
49
|
+
transport: SyncTransport | None = None,
|
|
50
|
+
hooks: Hooks | None = None,
|
|
51
|
+
chunk_batch: bool = False,
|
|
52
|
+
) -> None:
|
|
53
|
+
options = ClientOptions(
|
|
54
|
+
api_key=api_key,
|
|
55
|
+
base_url=base_url,
|
|
56
|
+
timeout_ms=timeout_ms,
|
|
57
|
+
retry=retry or RetryPolicy(),
|
|
58
|
+
default_headers=dict(default_headers or {}),
|
|
59
|
+
chunk_batch=chunk_batch,
|
|
60
|
+
)
|
|
61
|
+
self._options = options
|
|
62
|
+
self._http = HttpClient(options, transport=transport, hooks=hooks)
|
|
63
|
+
self.tax = TaxResource(self._http, chunk_batch=chunk_batch)
|
|
64
|
+
self.rates = RatesResource(self._http)
|
|
65
|
+
self.jurisdictions = JurisdictionsResource(self._http)
|
|
66
|
+
|
|
67
|
+
@classmethod
|
|
68
|
+
def from_env(cls, **overrides: Any) -> SalesTaxClient:
|
|
69
|
+
"""Construct using ``SALESTAX_API_KEY`` from the environment."""
|
|
70
|
+
return cls(**overrides)
|
|
71
|
+
|
|
72
|
+
def close(self) -> None:
|
|
73
|
+
"""Release transport resources."""
|
|
74
|
+
self._http.close()
|
|
75
|
+
|
|
76
|
+
def __enter__(self) -> SalesTaxClient:
|
|
77
|
+
return self
|
|
78
|
+
|
|
79
|
+
def __exit__(self, *exc: object) -> None:
|
|
80
|
+
self.close()
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class AsyncSalesTaxClient:
|
|
84
|
+
"""Asynchronous client. Requires the ``[async]`` extra.
|
|
85
|
+
|
|
86
|
+
Example:
|
|
87
|
+
>>> async with AsyncSalesTaxClient() as client:
|
|
88
|
+
... tax = await client.tax.calculate(zip_code="90210", amount=100)
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
api_key: API key. Falls back to ``SALESTAX_API_KEY`` when omitted.
|
|
92
|
+
base_url: Override the API base URL.
|
|
93
|
+
timeout_ms: Per-request timeout in milliseconds.
|
|
94
|
+
retry: Custom retry policy.
|
|
95
|
+
default_headers: Headers merged into every request.
|
|
96
|
+
transport: Custom transport implementing :class:`AsyncTransport`.
|
|
97
|
+
hooks: Lifecycle callbacks.
|
|
98
|
+
chunk_batch: If True, ``calculate_batch`` auto-splits inputs >100.
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
def __init__(
|
|
102
|
+
self,
|
|
103
|
+
api_key: str | None = None,
|
|
104
|
+
*,
|
|
105
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
106
|
+
timeout_ms: int = DEFAULT_TIMEOUT_MS,
|
|
107
|
+
retry: RetryPolicy | None = None,
|
|
108
|
+
default_headers: dict[str, str] | None = None,
|
|
109
|
+
transport: AsyncTransport | None = None,
|
|
110
|
+
hooks: Hooks | None = None,
|
|
111
|
+
chunk_batch: bool = False,
|
|
112
|
+
) -> None:
|
|
113
|
+
options = ClientOptions(
|
|
114
|
+
api_key=api_key,
|
|
115
|
+
base_url=base_url,
|
|
116
|
+
timeout_ms=timeout_ms,
|
|
117
|
+
retry=retry or RetryPolicy(),
|
|
118
|
+
default_headers=dict(default_headers or {}),
|
|
119
|
+
chunk_batch=chunk_batch,
|
|
120
|
+
)
|
|
121
|
+
self._options = options
|
|
122
|
+
self._http = AsyncHttpClient(options, transport=transport, hooks=hooks)
|
|
123
|
+
self.tax = _AsyncTaxResource(self._http, chunk_batch=chunk_batch)
|
|
124
|
+
self.rates = _AsyncRatesResource(self._http)
|
|
125
|
+
self.jurisdictions = _AsyncJurisdictionsResource(self._http)
|
|
126
|
+
|
|
127
|
+
@classmethod
|
|
128
|
+
def from_env(cls, **overrides: Any) -> AsyncSalesTaxClient:
|
|
129
|
+
return cls(**overrides)
|
|
130
|
+
|
|
131
|
+
async def aclose(self) -> None:
|
|
132
|
+
await self._http.aclose()
|
|
133
|
+
|
|
134
|
+
async def __aenter__(self) -> AsyncSalesTaxClient:
|
|
135
|
+
return self
|
|
136
|
+
|
|
137
|
+
async def __aexit__(self, *exc: object) -> None:
|
|
138
|
+
await self.aclose()
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
# -- Async resources --------------------------------------------------------
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class _AsyncTaxResource:
|
|
145
|
+
def __init__(self, http: Any, *, chunk_batch: bool = False) -> None:
|
|
146
|
+
self._http = http
|
|
147
|
+
self._chunk_batch = chunk_batch
|
|
148
|
+
|
|
149
|
+
async def calculate(self, **kwargs: Any) -> Any:
|
|
150
|
+
from .errors import ValidationError
|
|
151
|
+
|
|
152
|
+
if not kwargs.get("zip_code"):
|
|
153
|
+
raise ValidationError(
|
|
154
|
+
"MISSING_PARAM", "zip_code is required", status_code=400, param="zip_code"
|
|
155
|
+
)
|
|
156
|
+
payload: dict[str, Any] = {
|
|
157
|
+
"zipCode": kwargs["zip_code"],
|
|
158
|
+
"amount": kwargs["amount"],
|
|
159
|
+
}
|
|
160
|
+
for src, dst in (("state", "state"), ("country", "country"), ("city", "city")):
|
|
161
|
+
if kwargs.get(src) is not None:
|
|
162
|
+
payload[dst] = kwargs[src]
|
|
163
|
+
return await self._http.send("POST", "/calculate", body=payload)
|
|
164
|
+
|
|
165
|
+
async def calculate_batch(self, transactions: Any, **kwargs: Any) -> Any:
|
|
166
|
+
from ._config import MAX_BATCH_SIZE
|
|
167
|
+
from .errors import ValidationError
|
|
168
|
+
|
|
169
|
+
if not transactions:
|
|
170
|
+
raise ValidationError("EMPTY_BATCH", "Batch must not be empty", status_code=400)
|
|
171
|
+
if len(transactions) > MAX_BATCH_SIZE and not self._chunk_batch:
|
|
172
|
+
raise ValidationError(
|
|
173
|
+
"BATCH_LIMIT_EXCEEDED",
|
|
174
|
+
f"Batch is limited to {MAX_BATCH_SIZE} transactions.",
|
|
175
|
+
status_code=400,
|
|
176
|
+
)
|
|
177
|
+
return await self._http.send(
|
|
178
|
+
"POST", "/calculate/batch", body={"transactions": list(transactions)}
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
async def calculate_batch_chunked(self, transactions: Any, **kwargs: Any) -> Any:
|
|
182
|
+
from ._config import MAX_BATCH_SIZE
|
|
183
|
+
from .utils import chunk
|
|
184
|
+
|
|
185
|
+
results = []
|
|
186
|
+
for batch in chunk(list(transactions), MAX_BATCH_SIZE):
|
|
187
|
+
res = await self.calculate_batch(batch, **kwargs)
|
|
188
|
+
results.extend(res.get("results", []))
|
|
189
|
+
return {"results": results, "count": len(results)}
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
class _AsyncRatesResource:
|
|
193
|
+
def __init__(self, http: Any) -> None:
|
|
194
|
+
self._http = http
|
|
195
|
+
|
|
196
|
+
async def get(self, zip_code: str, **kwargs: Any) -> Any:
|
|
197
|
+
from urllib.parse import quote
|
|
198
|
+
|
|
199
|
+
return await self._http.send("GET", f"/rates/{quote(zip_code, safe='')}")
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
class _AsyncJurisdictionsResource:
|
|
203
|
+
def __init__(self, http: Any) -> None:
|
|
204
|
+
self._http = http
|
|
205
|
+
|
|
206
|
+
async def list(self, **kwargs: Any) -> Any:
|
|
207
|
+
from urllib.parse import urlencode
|
|
208
|
+
|
|
209
|
+
params = {
|
|
210
|
+
k: v
|
|
211
|
+
for k, v in {"country": kwargs.get("country"), "state": kwargs.get("state")}.items()
|
|
212
|
+
if v is not None
|
|
213
|
+
}
|
|
214
|
+
query = f"?{urlencode(params)}" if params else ""
|
|
215
|
+
return await self._http.send("GET", f"/jurisdictions{query}")
|
salestax/errors.py
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""Error hierarchy.
|
|
2
|
+
|
|
3
|
+
All SDK errors inherit from :class:`SalesTaxError`. API errors inherit from
|
|
4
|
+
:class:`ApiError` and carry a ``status_code``, ``code``, ``request_id``, and
|
|
5
|
+
(where relevant) ``param``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Mapping
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class SalesTaxError(Exception):
|
|
15
|
+
"""Base class for all SDK errors."""
|
|
16
|
+
|
|
17
|
+
code: str = "UNKNOWN"
|
|
18
|
+
retryable: bool = False
|
|
19
|
+
|
|
20
|
+
def __init__(
|
|
21
|
+
self,
|
|
22
|
+
code: str,
|
|
23
|
+
message: str,
|
|
24
|
+
*,
|
|
25
|
+
status_code: int | None = None,
|
|
26
|
+
request_id: str | None = None,
|
|
27
|
+
retryable: bool | None = None,
|
|
28
|
+
) -> None:
|
|
29
|
+
super().__init__(message)
|
|
30
|
+
self.code = code
|
|
31
|
+
self.message = message
|
|
32
|
+
self.status_code = status_code
|
|
33
|
+
self.request_id = request_id
|
|
34
|
+
if retryable is not None:
|
|
35
|
+
self.retryable = retryable
|
|
36
|
+
|
|
37
|
+
def __repr__(self) -> str: # pragma: no cover - cosmetic
|
|
38
|
+
return (
|
|
39
|
+
f"{type(self).__name__}(code={self.code!r}, "
|
|
40
|
+
f"status_code={self.status_code!r}, message={self.message!r})"
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# -- API errors -------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class ApiError(SalesTaxError):
|
|
48
|
+
"""Base class for errors returned by the API (4xx / 5xx)."""
|
|
49
|
+
|
|
50
|
+
def __init__(
|
|
51
|
+
self,
|
|
52
|
+
code: str,
|
|
53
|
+
message: str,
|
|
54
|
+
*,
|
|
55
|
+
status_code: int,
|
|
56
|
+
request_id: str | None = None,
|
|
57
|
+
retryable: bool = False,
|
|
58
|
+
param: str | None = None,
|
|
59
|
+
) -> None:
|
|
60
|
+
super().__init__(
|
|
61
|
+
code, message, status_code=status_code, request_id=request_id, retryable=retryable
|
|
62
|
+
)
|
|
63
|
+
self.param = param
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class AuthenticationError(ApiError):
|
|
67
|
+
"""401 — API key missing or invalid."""
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class PermissionError(ApiError):
|
|
71
|
+
"""403 — key lacks access to the requested resource."""
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class ValidationError(ApiError):
|
|
75
|
+
"""400 / 422 — request failed validation. Inspect ``param``."""
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class NotFoundError(ApiError):
|
|
79
|
+
"""404 — resource or jurisdiction not found."""
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class ConflictError(ApiError):
|
|
83
|
+
"""409 — request conflicts with current state."""
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class ServerError(ApiError):
|
|
87
|
+
"""5xx — server-side failure. Retryable."""
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class RateLimitError(ApiError):
|
|
91
|
+
"""429 — rate limit exceeded. ``retry_after_ms`` may be present."""
|
|
92
|
+
|
|
93
|
+
def __init__(
|
|
94
|
+
self,
|
|
95
|
+
code: str,
|
|
96
|
+
message: str,
|
|
97
|
+
*,
|
|
98
|
+
status_code: int,
|
|
99
|
+
request_id: str | None = None,
|
|
100
|
+
param: str | None = None,
|
|
101
|
+
retry_after_ms: int | None = None,
|
|
102
|
+
) -> None:
|
|
103
|
+
super().__init__(
|
|
104
|
+
code,
|
|
105
|
+
message,
|
|
106
|
+
status_code=status_code,
|
|
107
|
+
request_id=request_id,
|
|
108
|
+
retryable=True,
|
|
109
|
+
param=param,
|
|
110
|
+
)
|
|
111
|
+
self.retry_after_ms = retry_after_ms
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
# -- Transport errors -------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class ConnectionError(SalesTaxError):
|
|
118
|
+
"""Network failure: DNS, TLS, socket, reset. Retryable by default."""
|
|
119
|
+
|
|
120
|
+
def __init__(
|
|
121
|
+
self,
|
|
122
|
+
message: str,
|
|
123
|
+
*,
|
|
124
|
+
code: str = "CONNECTION_ERROR",
|
|
125
|
+
retryable: bool = True,
|
|
126
|
+
) -> None:
|
|
127
|
+
super().__init__(code, message, retryable=retryable)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class TimeoutError(ConnectionError):
|
|
131
|
+
"""Request exceeded the configured timeout."""
|
|
132
|
+
|
|
133
|
+
def __init__(self, timeout_ms: int) -> None:
|
|
134
|
+
super().__init__(f"Request timed out after {timeout_ms}ms", code="TIMEOUT")
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
# -- Status → error mapping -------------------------------------------------
|
|
138
|
+
|
|
139
|
+
_STATUS_MAP: Mapping[int, type[ApiError]] = {
|
|
140
|
+
400: ValidationError,
|
|
141
|
+
401: AuthenticationError,
|
|
142
|
+
403: PermissionError,
|
|
143
|
+
404: NotFoundError,
|
|
144
|
+
409: ConflictError,
|
|
145
|
+
422: ValidationError,
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def error_from_response(
|
|
150
|
+
status_code: int,
|
|
151
|
+
body: Mapping[str, Any],
|
|
152
|
+
*,
|
|
153
|
+
request_id: str | None = None,
|
|
154
|
+
retry_after_ms: int | None = None,
|
|
155
|
+
) -> ApiError:
|
|
156
|
+
"""Map an HTTP status + JSON body into the appropriate ``ApiError`` subclass."""
|
|
157
|
+
code = str(body.get("code") or f"HTTP_{status_code}")
|
|
158
|
+
message = str(body.get("message") or f"Request failed with status {status_code}")
|
|
159
|
+
param = body.get("param")
|
|
160
|
+
param_str = str(param) if param is not None else None
|
|
161
|
+
|
|
162
|
+
if status_code == 429:
|
|
163
|
+
return RateLimitError(
|
|
164
|
+
code,
|
|
165
|
+
message,
|
|
166
|
+
status_code=status_code,
|
|
167
|
+
request_id=request_id,
|
|
168
|
+
param=param_str,
|
|
169
|
+
retry_after_ms=retry_after_ms,
|
|
170
|
+
)
|
|
171
|
+
if status_code >= 500:
|
|
172
|
+
return ServerError(
|
|
173
|
+
code,
|
|
174
|
+
message,
|
|
175
|
+
status_code=status_code,
|
|
176
|
+
request_id=request_id,
|
|
177
|
+
retryable=True,
|
|
178
|
+
param=param_str,
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
ctor: type[ApiError] = _STATUS_MAP.get(status_code, ApiError)
|
|
182
|
+
if ctor is ApiError:
|
|
183
|
+
return ApiError(
|
|
184
|
+
code, message, status_code=status_code, request_id=request_id, param=param_str
|
|
185
|
+
)
|
|
186
|
+
return ctor(code, message, status_code=status_code, request_id=request_id, param=param_str)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
__all__ = [
|
|
190
|
+
"ApiError",
|
|
191
|
+
"AuthenticationError",
|
|
192
|
+
"ConflictError",
|
|
193
|
+
"ConnectionError",
|
|
194
|
+
"NotFoundError",
|
|
195
|
+
"PermissionError",
|
|
196
|
+
"RateLimitError",
|
|
197
|
+
"SalesTaxError",
|
|
198
|
+
"ServerError",
|
|
199
|
+
"TimeoutError",
|
|
200
|
+
"ValidationError",
|
|
201
|
+
"error_from_response",
|
|
202
|
+
]
|
salestax/models.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Typed request and response shapes.
|
|
2
|
+
|
|
3
|
+
Uses ``TypedDict`` so the objects remain plain ``dict`` at runtime — no
|
|
4
|
+
deserialization overhead, and users get full IDE autocomplete on responses.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import TypedDict
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class _CalculateTaxParamsRequired(TypedDict):
|
|
13
|
+
"""Required fields for a single tax calculation."""
|
|
14
|
+
|
|
15
|
+
zipCode: str
|
|
16
|
+
amount: float
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class CalculateTaxParams(_CalculateTaxParamsRequired, total=False):
|
|
20
|
+
"""Parameters for a single tax calculation.
|
|
21
|
+
|
|
22
|
+
``zipCode`` and ``amount`` are required (inherited from
|
|
23
|
+
:class:`_CalculateTaxParamsRequired`); ``state``, ``country`` and ``city``
|
|
24
|
+
are optional.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
state: str
|
|
28
|
+
country: str
|
|
29
|
+
city: str
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class TaxBreakdownEntry(TypedDict, total=False):
|
|
33
|
+
"""A single jurisdiction's contribution to the total tax."""
|
|
34
|
+
|
|
35
|
+
jurisdiction: str
|
|
36
|
+
name: str
|
|
37
|
+
rate: float
|
|
38
|
+
amount: float
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class TaxCalculation(TypedDict, total=False):
|
|
42
|
+
"""Response shape for a single calculation."""
|
|
43
|
+
|
|
44
|
+
taxAmount: float
|
|
45
|
+
totalAmount: float
|
|
46
|
+
rate: float
|
|
47
|
+
jurisdiction: str
|
|
48
|
+
breakdown: list[TaxBreakdownEntry]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class BatchResult(TypedDict):
|
|
52
|
+
"""Response shape for batch calculations."""
|
|
53
|
+
|
|
54
|
+
results: list[TaxCalculation]
|
|
55
|
+
count: int
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class TaxRate(TypedDict, total=False):
|
|
59
|
+
"""Response for a rate lookup."""
|
|
60
|
+
|
|
61
|
+
zipCode: str
|
|
62
|
+
rate: float
|
|
63
|
+
jurisdiction: str
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class Jurisdiction(TypedDict, total=False):
|
|
67
|
+
"""A supported jurisdiction."""
|
|
68
|
+
|
|
69
|
+
code: str
|
|
70
|
+
name: str
|
|
71
|
+
country: str
|
|
72
|
+
state: str | None
|
|
73
|
+
type: str
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class JurisdictionQuery(TypedDict, total=False):
|
|
77
|
+
"""Query filters for listing jurisdictions."""
|
|
78
|
+
|
|
79
|
+
country: str
|
|
80
|
+
state: str
|
salestax/py.typed
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# Marker file for PEP 561. Empty on purpose.
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""``client.jurisdictions`` resource."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
from urllib.parse import urlencode
|
|
7
|
+
|
|
8
|
+
from ..models import Jurisdiction
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class JurisdictionsResource:
|
|
12
|
+
"""Jurisdiction listing endpoints. Do not instantiate directly."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, http: Any) -> None:
|
|
15
|
+
self._http = http
|
|
16
|
+
|
|
17
|
+
def list(
|
|
18
|
+
self,
|
|
19
|
+
*,
|
|
20
|
+
country: str | None = None,
|
|
21
|
+
state: str | None = None,
|
|
22
|
+
retryable: bool = True,
|
|
23
|
+
) -> list[Jurisdiction]:
|
|
24
|
+
"""List supported jurisdictions, optionally filtered."""
|
|
25
|
+
params = {k: v for k, v in {"country": country, "state": state}.items() if v is not None}
|
|
26
|
+
query = f"?{urlencode(params)}" if params else ""
|
|
27
|
+
return self._http.send("GET", f"/jurisdictions{query}") # type: ignore[no-any-return]
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""``client.rates`` resource."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
from urllib.parse import quote
|
|
7
|
+
|
|
8
|
+
from ..errors import ValidationError
|
|
9
|
+
from ..models import TaxRate
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class RatesResource:
|
|
13
|
+
"""Rate lookup endpoints. Do not instantiate directly."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, http: Any) -> None:
|
|
16
|
+
self._http = http
|
|
17
|
+
|
|
18
|
+
def get(self, zip_code: str, *, retryable: bool = True) -> TaxRate:
|
|
19
|
+
"""Return the effective tax rate for a ZIP / postal code."""
|
|
20
|
+
if not zip_code:
|
|
21
|
+
raise ValidationError(
|
|
22
|
+
"MISSING_PARAM", "zip_code is required", status_code=400, param="zip_code"
|
|
23
|
+
)
|
|
24
|
+
path = f"/rates/{quote(zip_code, safe='')}"
|
|
25
|
+
return self._http.send("GET", path) # type: ignore[no-any-return]
|