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 ADDED
@@ -0,0 +1,61 @@
1
+ """Official Python SDK for the Sales Tax Calculator API.
2
+
3
+ Real-time sales tax for 70+ countries, 51 US jurisdictions, and 13 Canadian
4
+ provinces. Batch up to 100 transactions per call.
5
+
6
+ Docs: https://salestaxcalculatorapi.com/docs
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from ._config import ClientOptions, RetryPolicy
12
+ from ._version import __version__
13
+ from .client import AsyncSalesTaxClient, SalesTaxClient
14
+ from .errors import (
15
+ ApiError,
16
+ AuthenticationError,
17
+ ConflictError,
18
+ ConnectionError,
19
+ NotFoundError,
20
+ PermissionError,
21
+ RateLimitError,
22
+ SalesTaxError,
23
+ ServerError,
24
+ TimeoutError,
25
+ ValidationError,
26
+ )
27
+ from .models import (
28
+ BatchResult,
29
+ CalculateTaxParams,
30
+ Jurisdiction,
31
+ JurisdictionQuery,
32
+ TaxBreakdownEntry,
33
+ TaxCalculation,
34
+ TaxRate,
35
+ )
36
+
37
+ __all__ = [
38
+ "ApiError",
39
+ "AsyncSalesTaxClient",
40
+ "AuthenticationError",
41
+ "BatchResult",
42
+ "CalculateTaxParams",
43
+ "ClientOptions",
44
+ "ConflictError",
45
+ "ConnectionError",
46
+ "Jurisdiction",
47
+ "JurisdictionQuery",
48
+ "NotFoundError",
49
+ "PermissionError",
50
+ "RateLimitError",
51
+ "RetryPolicy",
52
+ "SalesTaxClient",
53
+ "SalesTaxError",
54
+ "ServerError",
55
+ "TaxBreakdownEntry",
56
+ "TaxCalculation",
57
+ "TaxRate",
58
+ "TimeoutError",
59
+ "ValidationError",
60
+ "__version__",
61
+ ]
salestax/_config.py ADDED
@@ -0,0 +1,53 @@
1
+ """Configuration and defaults for the SDK."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from dataclasses import dataclass, field, replace
7
+
8
+ DEFAULT_BASE_URL = "https://api.salestaxcalculatorapi.com/v1"
9
+ DEFAULT_TIMEOUT_MS = 30_000
10
+ MAX_BATCH_SIZE = 100
11
+ ENV_API_KEY = "SALESTAX_API_KEY"
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class RetryPolicy:
16
+ """Retry behavior for transient failures.
17
+
18
+ Attributes:
19
+ max_retries: Number of retries *after* the initial attempt.
20
+ initial_delay_ms: Base delay for the first retry.
21
+ max_delay_ms: Ceiling for any single delay.
22
+ backoff_factor: Exponential multiplier applied per attempt.
23
+ jitter: Apply full jitter to each computed delay.
24
+ """
25
+
26
+ max_retries: int = 2
27
+ initial_delay_ms: int = 250
28
+ max_delay_ms: int = 8_000
29
+ backoff_factor: float = 2.0
30
+ jitter: bool = True
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class ClientOptions:
35
+ """Immutable options container. Prefer passing keyword args to the client."""
36
+
37
+ api_key: str | None = None
38
+ base_url: str = DEFAULT_BASE_URL
39
+ timeout_ms: int = DEFAULT_TIMEOUT_MS
40
+ retry: RetryPolicy = field(default_factory=RetryPolicy)
41
+ default_headers: dict[str, str] = field(default_factory=dict)
42
+ chunk_batch: bool = False
43
+
44
+ def resolved_api_key(self) -> str:
45
+ key = self.api_key or os.environ.get(ENV_API_KEY)
46
+ if not key:
47
+ raise ValueError(
48
+ f"Missing API key. Pass api_key=... or set the {ENV_API_KEY} environment variable."
49
+ )
50
+ return key
51
+
52
+ def with_overrides(self, **kwargs: object) -> ClientOptions:
53
+ return replace(self, **kwargs) # type: ignore[arg-type]
@@ -0,0 +1,18 @@
1
+ from .http_client import AsyncHttpClient, HttpClient
2
+ from .retry import compute_delay_ms, parse_retry_after
3
+ from .types import AsyncTransport, Hooks, RequestOptions, SyncTransport
4
+ from .urllib_transport import UrllibTransport
5
+ from .user_agent import build_user_agent
6
+
7
+ __all__ = [
8
+ "AsyncHttpClient",
9
+ "AsyncTransport",
10
+ "Hooks",
11
+ "HttpClient",
12
+ "RequestOptions",
13
+ "SyncTransport",
14
+ "UrllibTransport",
15
+ "build_user_agent",
16
+ "compute_delay_ms",
17
+ "parse_retry_after",
18
+ ]
@@ -0,0 +1,287 @@
1
+ """High-level HTTP clients (sync + async).
2
+
3
+ Owns the full request lifecycle:
4
+
5
+ 1. Serialize body / build headers (auth, UA, idempotency).
6
+ 2. Delegate to the transport.
7
+ 3. Retry on retryable failures with backoff + jitter, honoring ``Retry-After``.
8
+ 4. Map non-2xx responses to typed errors.
9
+ 5. Fire lifecycle hooks.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import asyncio
15
+ import json
16
+ import time
17
+ from collections.abc import Mapping
18
+ from typing import Any, TypeVar
19
+
20
+ from .._config import ClientOptions, RetryPolicy
21
+ from ..errors import SalesTaxError, error_from_response
22
+ from .retry import compute_delay_ms, parse_retry_after
23
+ from .types import AsyncTransport, Hooks, RequestOptions, SyncTransport
24
+ from .urllib_transport import UrllibTransport
25
+ from .user_agent import build_user_agent
26
+
27
+ T = TypeVar("T")
28
+
29
+ _JSON_DECODE_ERRORS = (json.JSONDecodeError, UnicodeDecodeError, ValueError)
30
+
31
+
32
+ # -- Shared helpers ---------------------------------------------------------
33
+
34
+
35
+ def _build_headers(
36
+ api_key: str,
37
+ options: ClientOptions,
38
+ request_options: RequestOptions | None,
39
+ ) -> dict[str, str]:
40
+ headers: dict[str, str] = {
41
+ "Authorization": f"Bearer {api_key}",
42
+ "Content-Type": "application/json",
43
+ "Accept": "application/json",
44
+ "User-Agent": build_user_agent(),
45
+ }
46
+ headers.update(options.default_headers)
47
+ if request_options and request_options.headers:
48
+ headers.update(request_options.headers)
49
+ if request_options and request_options.idempotency_key:
50
+ headers["Idempotency-Key"] = request_options.idempotency_key
51
+ return headers
52
+
53
+
54
+ def _extract_request_id(headers: Mapping[str, str]) -> str | None:
55
+ for key in ("x-request-id", "request-id", "X-Request-Id"):
56
+ if key in headers:
57
+ return headers[key]
58
+ return None
59
+
60
+
61
+ def _decode_json(raw: bytes) -> Mapping[str, Any]:
62
+ if not raw:
63
+ return {}
64
+ try:
65
+ parsed = json.loads(raw)
66
+ except _JSON_DECODE_ERRORS:
67
+ return {}
68
+ return parsed if isinstance(parsed, Mapping) else {}
69
+
70
+
71
+ def _should_retry(
72
+ err: SalesTaxError,
73
+ attempt: int,
74
+ policy: RetryPolicy,
75
+ request_options: RequestOptions | None,
76
+ ) -> bool:
77
+ if request_options and request_options.retryable is False:
78
+ return False
79
+ if not err.retryable:
80
+ return False
81
+ return attempt < policy.max_retries
82
+
83
+
84
+ # -- Sync client ------------------------------------------------------------
85
+
86
+
87
+ class HttpClient:
88
+ """Synchronous HTTP client with retry and typed error mapping."""
89
+
90
+ def __init__(
91
+ self,
92
+ options: ClientOptions,
93
+ *,
94
+ transport: SyncTransport | None = None,
95
+ hooks: Hooks | None = None,
96
+ ) -> None:
97
+ self._options = options
98
+ self._api_key = options.resolved_api_key()
99
+ self._base_url = options.base_url.rstrip("/")
100
+ self._timeout_s = options.timeout_ms / 1000.0
101
+ self._retry = options.retry
102
+ self._transport: SyncTransport = transport or UrllibTransport()
103
+ self._hooks = hooks or Hooks()
104
+
105
+ def send(
106
+ self,
107
+ method: str,
108
+ path: str,
109
+ *,
110
+ body: Mapping[str, Any] | None = None,
111
+ options: RequestOptions | None = None,
112
+ ) -> Any:
113
+ url = f"{self._base_url}{path}"
114
+ headers = _build_headers(self._api_key, self._options, options)
115
+ encoded = json.dumps(body).encode("utf-8") if body is not None else None
116
+
117
+ attempt = 0
118
+ last_error: SalesTaxError | None = None
119
+
120
+ while attempt <= self._retry.max_retries:
121
+ started = time.monotonic()
122
+ self._hooks.fire("on_request", {"method": method, "url": url, "attempt": attempt})
123
+ try:
124
+ status, resp_headers, raw = self._transport.request(
125
+ method,
126
+ url,
127
+ headers=headers,
128
+ body=encoded,
129
+ timeout_s=self._timeout_s,
130
+ )
131
+ except SalesTaxError as exc:
132
+ last_error = exc
133
+ if not _should_retry(exc, attempt, self._retry, options):
134
+ raise
135
+ delay = compute_delay_ms(attempt, self._retry)
136
+ self._hooks.fire(
137
+ "on_retry", {"attempt": attempt + 1, "delay_ms": delay, "error": exc}
138
+ )
139
+ time.sleep(delay / 1000.0)
140
+ attempt += 1
141
+ continue
142
+
143
+ request_id = _extract_request_id(resp_headers)
144
+ self._hooks.fire(
145
+ "on_response",
146
+ {
147
+ "status": status,
148
+ "url": url,
149
+ "duration_ms": int((time.monotonic() - started) * 1000),
150
+ "request_id": request_id,
151
+ },
152
+ )
153
+
154
+ if 200 <= status < 300:
155
+ return _decode_json(raw)
156
+
157
+ parsed = _decode_json(raw)
158
+ err = error_from_response(
159
+ status,
160
+ parsed,
161
+ request_id=request_id,
162
+ retry_after_ms=parse_retry_after(resp_headers.get("retry-after")),
163
+ )
164
+ last_error = err
165
+
166
+ if not _should_retry(err, attempt, self._retry, options):
167
+ raise err
168
+
169
+ retry_after = getattr(err, "retry_after_ms", None)
170
+ delay = compute_delay_ms(attempt, self._retry, retry_after_ms=retry_after)
171
+ self._hooks.fire("on_retry", {"attempt": attempt + 1, "delay_ms": delay, "error": err})
172
+ time.sleep(delay / 1000.0)
173
+ attempt += 1
174
+
175
+ assert last_error is not None
176
+ raise last_error
177
+
178
+ def close(self) -> None:
179
+ """Release transport resources. No-op for the default transport."""
180
+
181
+
182
+ # -- Async client -----------------------------------------------------------
183
+
184
+
185
+ class AsyncHttpClient:
186
+ """Asynchronous HTTP client with retry and typed error mapping."""
187
+
188
+ def __init__(
189
+ self,
190
+ options: ClientOptions,
191
+ *,
192
+ transport: AsyncTransport | None = None,
193
+ hooks: Hooks | None = None,
194
+ ) -> None:
195
+ self._options = options
196
+ self._api_key = options.resolved_api_key()
197
+ self._base_url = options.base_url.rstrip("/")
198
+ self._timeout_s = options.timeout_ms / 1000.0
199
+ self._retry = options.retry
200
+ if transport is None:
201
+ from .httpx_transport import HttpxAsyncTransport
202
+
203
+ transport = HttpxAsyncTransport()
204
+ self._transport: AsyncTransport = transport
205
+ self._hooks = hooks or Hooks()
206
+
207
+ async def send(
208
+ self,
209
+ method: str,
210
+ path: str,
211
+ *,
212
+ body: Mapping[str, Any] | None = None,
213
+ options: RequestOptions | None = None,
214
+ ) -> Any:
215
+ url = f"{self._base_url}{path}"
216
+ headers = _build_headers(self._api_key, self._options, options)
217
+ encoded = json.dumps(body).encode("utf-8") if body is not None else None
218
+
219
+ attempt = 0
220
+ last_error: SalesTaxError | None = None
221
+
222
+ while attempt <= self._retry.max_retries:
223
+ started = time.monotonic()
224
+ self._hooks.fire("on_request", {"method": method, "url": url, "attempt": attempt})
225
+ try:
226
+ status, resp_headers, raw = await self._transport.request(
227
+ method,
228
+ url,
229
+ headers=headers,
230
+ body=encoded,
231
+ timeout_s=self._timeout_s,
232
+ )
233
+ except SalesTaxError as exc:
234
+ last_error = exc
235
+ if not _should_retry(exc, attempt, self._retry, options):
236
+ raise
237
+ delay = compute_delay_ms(attempt, self._retry)
238
+ self._hooks.fire(
239
+ "on_retry", {"attempt": attempt + 1, "delay_ms": delay, "error": exc}
240
+ )
241
+ await asyncio.sleep(delay / 1000.0)
242
+ attempt += 1
243
+ continue
244
+
245
+ request_id = _extract_request_id(resp_headers)
246
+ self._hooks.fire(
247
+ "on_response",
248
+ {
249
+ "status": status,
250
+ "url": url,
251
+ "duration_ms": int((time.monotonic() - started) * 1000),
252
+ "request_id": request_id,
253
+ },
254
+ )
255
+
256
+ if 200 <= status < 300:
257
+ return _decode_json(raw)
258
+
259
+ parsed = _decode_json(raw)
260
+ err = error_from_response(
261
+ status,
262
+ parsed,
263
+ request_id=request_id,
264
+ retry_after_ms=parse_retry_after(resp_headers.get("retry-after")),
265
+ )
266
+ last_error = err
267
+
268
+ if not _should_retry(err, attempt, self._retry, options):
269
+ raise err
270
+
271
+ retry_after = getattr(err, "retry_after_ms", None)
272
+ delay = compute_delay_ms(attempt, self._retry, retry_after_ms=retry_after)
273
+ self._hooks.fire("on_retry", {"attempt": attempt + 1, "delay_ms": delay, "error": err})
274
+ await asyncio.sleep(delay / 1000.0)
275
+ attempt += 1
276
+
277
+ assert last_error is not None
278
+ raise last_error
279
+
280
+ async def aclose(self) -> None:
281
+ await self._transport.aclose()
282
+
283
+ async def __aenter__(self) -> AsyncHttpClient:
284
+ return self
285
+
286
+ async def __aexit__(self, *exc: object) -> None:
287
+ await self.aclose()
@@ -0,0 +1,54 @@
1
+ """Optional async transport backed by httpx.
2
+
3
+ Only imported when the user calls :class:`AsyncSalesTaxClient` or passes an
4
+ instance explicitly. Installing the ``[async]`` extra is required.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from collections.abc import Mapping
10
+
11
+ from ..errors import ConnectionError, TimeoutError
12
+
13
+ try:
14
+ import httpx
15
+ except ImportError as exc: # pragma: no cover - import-guard
16
+ raise ImportError(
17
+ "The async client requires httpx. Install with: pip install salestax-python[async]"
18
+ ) from exc
19
+
20
+
21
+ class HttpxAsyncTransport:
22
+ """httpx.AsyncClient-backed transport. One connection pool per instance."""
23
+
24
+ def __init__(self) -> None:
25
+ self._client = httpx.AsyncClient()
26
+
27
+ async def request(
28
+ self,
29
+ method: str,
30
+ url: str,
31
+ *,
32
+ headers: Mapping[str, str],
33
+ body: bytes | None,
34
+ timeout_s: float,
35
+ ) -> tuple[int, Mapping[str, str], bytes]:
36
+ try:
37
+ resp = await self._client.request(
38
+ method, url, headers=dict(headers), content=body, timeout=timeout_s
39
+ )
40
+ except httpx.TimeoutException as exc:
41
+ raise TimeoutError(int(timeout_s * 1000)) from exc
42
+ except httpx.TransportError as exc:
43
+ raise ConnectionError(str(exc)) from exc
44
+
45
+ return resp.status_code, dict(resp.headers), resp.content
46
+
47
+ async def aclose(self) -> None:
48
+ await self._client.aclose()
49
+
50
+ async def __aenter__(self) -> HttpxAsyncTransport:
51
+ return self
52
+
53
+ async def __aexit__(self, *exc: object) -> None:
54
+ await self.aclose()
@@ -0,0 +1,49 @@
1
+ """Backoff computation and ``Retry-After`` parsing."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import random
6
+ from datetime import datetime, timezone
7
+ from email.utils import parsedate_to_datetime
8
+
9
+ from .._config import RetryPolicy
10
+
11
+
12
+ def compute_delay_ms(
13
+ attempt: int,
14
+ policy: RetryPolicy,
15
+ *,
16
+ retry_after_ms: int | None = None,
17
+ ) -> int:
18
+ """Return the delay in milliseconds before retry ``attempt`` (0-indexed)."""
19
+ if retry_after_ms is not None:
20
+ return max(0, retry_after_ms)
21
+
22
+ exponential = policy.initial_delay_ms * (policy.backoff_factor**attempt)
23
+ capped = min(exponential, policy.max_delay_ms)
24
+ if policy.jitter:
25
+ return int(random.random() * capped)
26
+ return int(capped)
27
+
28
+
29
+ def parse_retry_after(value: str | None) -> int | None:
30
+ """Parse a ``Retry-After`` header into milliseconds.
31
+
32
+ Supports both the delay-seconds and HTTP-date forms per RFC 7231.
33
+ Returns ``None`` when the header is absent or unparseable.
34
+ """
35
+ if not value:
36
+ return None
37
+ value = value.strip()
38
+ if value.isdigit():
39
+ return int(value) * 1000
40
+ try:
41
+ when = parsedate_to_datetime(value)
42
+ except (TypeError, ValueError):
43
+ return None
44
+ if when is None:
45
+ return None
46
+ if when.tzinfo is None:
47
+ when = when.replace(tzinfo=timezone.utc)
48
+ delta_ms = int((when - datetime.now(timezone.utc)).total_seconds() * 1000)
49
+ return max(0, delta_ms)
@@ -0,0 +1,91 @@
1
+ """Transport-layer contracts: request options, hooks, transport protocol."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ from collections.abc import Callable, Mapping
7
+ from dataclasses import dataclass
8
+ from typing import Any, Protocol
9
+
10
+ from ..errors import SalesTaxError
11
+
12
+ # -- Request options --------------------------------------------------------
13
+
14
+
15
+ @dataclass
16
+ class RequestOptions:
17
+ """Per-request overrides."""
18
+
19
+ idempotency_key: str | None = None
20
+ headers: dict[str, str] | None = None
21
+ retryable: bool = True
22
+
23
+
24
+ # -- Hooks ------------------------------------------------------------------
25
+
26
+
27
+ @dataclass
28
+ class Hooks:
29
+ """Lifecycle callbacks. All are optional and called synchronously.
30
+
31
+ Exceptions raised inside hooks are swallowed so a buggy hook cannot
32
+ break request flow.
33
+ """
34
+
35
+ on_request: Callable[[dict[str, Any]], None] | None = None
36
+ on_response: Callable[[dict[str, Any]], None] | None = None
37
+ on_retry: Callable[[dict[str, Any]], None] | None = None
38
+
39
+ def fire(self, name: str, info: Mapping[str, Any]) -> None:
40
+ cb = getattr(self, name, None)
41
+ if cb is None:
42
+ return
43
+ with contextlib.suppress(Exception):
44
+ cb(dict(info))
45
+
46
+
47
+ # -- Transport protocol -----------------------------------------------------
48
+
49
+
50
+ class SyncTransport(Protocol):
51
+ """Minimal synchronous transport contract.
52
+
53
+ Implementations take a fully-prepared request and return a tuple of
54
+ ``(status_code, response_headers, raw_body_bytes)``. They must not
55
+ raise for non-2xx responses — only for genuine network failures.
56
+ """
57
+
58
+ def request(
59
+ self,
60
+ method: str,
61
+ url: str,
62
+ *,
63
+ headers: Mapping[str, str],
64
+ body: bytes | None,
65
+ timeout_s: float,
66
+ ) -> tuple[int, Mapping[str, str], bytes]: ...
67
+
68
+
69
+ class AsyncTransport(Protocol):
70
+ """Minimal asynchronous transport contract. Mirror of :class:`SyncTransport`."""
71
+
72
+ async def request(
73
+ self,
74
+ method: str,
75
+ url: str,
76
+ *,
77
+ headers: Mapping[str, str],
78
+ body: bytes | None,
79
+ timeout_s: float,
80
+ ) -> tuple[int, Mapping[str, str], bytes]: ...
81
+
82
+ async def aclose(self) -> None: ...
83
+
84
+
85
+ __all__ = [
86
+ "AsyncTransport",
87
+ "Hooks",
88
+ "RequestOptions",
89
+ "SalesTaxError",
90
+ "SyncTransport",
91
+ ]
@@ -0,0 +1,55 @@
1
+ """Default synchronous transport — stdlib only, zero dependencies."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import builtins
6
+ import socket
7
+ import ssl
8
+ import urllib.error
9
+ import urllib.request
10
+ from collections.abc import Mapping
11
+
12
+ from ..errors import ConnectionError, TimeoutError
13
+
14
+
15
+ class UrllibTransport:
16
+ """Stdlib-backed synchronous HTTP transport.
17
+
18
+ Only raises :class:`ConnectionError` and :class:`TimeoutError`.
19
+ HTTP status codes are returned to the caller as data, never raised.
20
+ """
21
+
22
+ def request(
23
+ self,
24
+ method: str,
25
+ url: str,
26
+ *,
27
+ headers: Mapping[str, str],
28
+ body: bytes | None,
29
+ timeout_s: float,
30
+ ) -> tuple[int, Mapping[str, str], bytes]:
31
+ req = urllib.request.Request(url, data=body, method=method)
32
+ for key, value in headers.items():
33
+ req.add_header(key, value)
34
+
35
+ # Order matters:
36
+ # HTTPError is a subclass of URLError and OSError
37
+ # URLError is a subclass of OSError
38
+ # TimeoutError/socket.timeout is a subclass of OSError
39
+ # Catch the most specific first.
40
+ try:
41
+ with urllib.request.urlopen(req, timeout=timeout_s) as resp:
42
+ return resp.status, dict(resp.headers.items()), resp.read()
43
+ except urllib.error.HTTPError as exc:
44
+ # HTTP errors still carry a response body we want to surface.
45
+ raw = exc.read()
46
+ return exc.code, dict(exc.headers.items()) if exc.headers else {}, raw
47
+ except urllib.error.URLError as exc:
48
+ reason = getattr(exc, "reason", exc)
49
+ if isinstance(reason, (socket.timeout, builtins.TimeoutError)):
50
+ raise TimeoutError(int(timeout_s * 1000)) from exc
51
+ raise ConnectionError(str(reason)) from exc
52
+ except (socket.timeout, builtins.TimeoutError) as exc:
53
+ raise TimeoutError(int(timeout_s * 1000)) from exc
54
+ except (ssl.SSLError, OSError) as exc:
55
+ raise ConnectionError(str(exc)) from exc
@@ -0,0 +1,15 @@
1
+ """User-Agent construction."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import platform
6
+ import sys
7
+
8
+ from .._version import __version__
9
+
10
+
11
+ def build_user_agent() -> str:
12
+ """Return a stable, informative User-Agent string."""
13
+ py = f"python/{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}"
14
+ os_info = f"{platform.system().lower()}/{platform.release()}"
15
+ return f"salestax-python/{__version__} ({py}; {os_info})"
salestax/_version.py ADDED
@@ -0,0 +1,3 @@
1
+ """Single source of truth for the SDK version."""
2
+
3
+ __version__ = "0.1.0"