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/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,5 @@
1
+ from .jurisdictions import JurisdictionsResource
2
+ from .rates import RatesResource
3
+ from .tax import TaxResource
4
+
5
+ __all__ = ["JurisdictionsResource", "RatesResource", "TaxResource"]
@@ -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]